Preliminary Checks
Reproduction
https://github.com/wuzzeb/clerk-auth-org
Publishable key
pk_test_ZmFzdC1iZWV0bGUtMjQuY2xlcmsuYWNjb3VudHMuZGV2JA
Description
High Level Picture: I have users in orgs and a user might be in more than one org. While most users will be in a single org, a consultant user will be a member of multiple orgs and will wish to have multiple orgs open in different tabs (so not a single active org, but different orgs they are consulting with).
I want to provide pages for each org like explained in https://clerk.com/docs/guides/organizations/org-slugs-in-urls . So I have a react single page application that on the client has a url such as https://domain/{orgId}/projects for example. The client extracts the {orgId} from the route and then makes a websocket connection to wss://domain/api/websocket/{orgId}. I now want to use authenticateRequest in the websocket handler on the server (cloudflare worker) to check if the user can access the org. Because it is websockets, the auth has to go through the cookies since can't set a bearer token.
Full Example Repository
I made an example github repository https://github.com/wuzzeb/clerk-auth-org I am using to test by starting with a fresh repository and adding only a little bit of code. See wuzzeb/clerk-auth-org@69e1582 for the code above the default scaffold. But below I post some snippets and my investigation of what the backend code is doing:
Server Code
On the server, the code looks like
const client = createClerkClient(...);
export default {
async fetch(req) {
if (req.method !== "GET") {
return new Response("Method Not Allowed", { status: 405 });
}
var url = new URL(req.url);
// path must be /api/websocket/{clerkOrgId}
if (!url.pathname.startsWith("/api/websocket/")) {
return new Response("Not Found", { status: 404 });
}
if (req.headers.get("Upgrade")?.toLowerCase() !== "websocket") {
return new Response("Expected WebSocket Upgrade", { status: 400 });
}
const parts = url.pathname.split("/");
if (parts.length !== 4 || !parts[3]) {
return new Response("Not Found", { status: 404 });
}
const clerkOrgId = parts[3];
const token = await client.authenticateRequest(req, {
organizationSyncOptions: {
organizationPatterns: ["/api/websocket/:id"],
},
});
if (!token.isAuthenticated) {
return new Response("Unauthorized", { status: 401 });
}
const auth = await token.toAuth();
if (auth.orgId !== clerkOrgId) {
return new Response("Forbidden", { status: 403 });
}
// more auth.has checks, etc.
},
}
Results
- If the currently active org matches the org id in the websocket URL, the call to
authenticateRequest works, everything matches.
- If the currently active org is a different org, I put a breakpoint there and single stepped through
- We go to
authenticateReqeustWithTokenInCookie
|
async function authenticateRequestWithTokenInCookie() { |
- It gets down to calling
handleMaybeOrganizationSyncHandshake at
|
const handshakeRequestState = handleMaybeOrganizationSyncHandshake(authenticateContext, authObject); |
handleMaybeOrganizationSyncHandshake correctly detects that a org switch is needed at line 378
|
if (organizationSyncTarget.organizationId && organizationSyncTarget.organizationId !== auth.orgId) { |
handleMaybeHandshakeStatus is called
|
const handshakeState = handleMaybeHandshakeStatus( |
- The first thing
handleMaybeHandshakeStatus does is call isRequestEligibleForHandshake
|
if (!handshakeService.isRequestEligibleForHandshake()) { |
isRequestEligibleForHandshake checks for the Sec-Fetch-Mode header
|
isRequestEligibleForHandshake(): boolean { |
Sec-Fetch-Mode is empty because this is a websocket request
isRequestEligibleForHandshake returns false, causing handleMaybeHandshakeStatus to return signedOut
handleMaybeOrganizationSyncHandshake returns null,
|
// Currently, this is only possible if we're in a redirect loop, but the above check should guard against that. |
- I think that comment is wrong, the comment says that returning null is only possible if we are in a redirect loop, but there have been no redirect loops yet.
Why is that sec-fetch-req check there in the first place? I don't understand isRequestEligibleForHandshake.
Workaround?
On the client, right before making the websocket connection, I can call
await clerk.setActive({organization: orgIdFromURL });
await clerk.session?.getToken({ skipCache: true });
const ws = new WebSocket(`wss://${window.location.host}/api/websocket/`${orgIdFromURL}`);
This works except if you have multiple tabs open there is a small timing window where things go wrong. I use ReconnectingWebsocket in that case and it seems to work. If two tabs are trying to connect at the same time, one will fail because it gets the wrong org, but then it will attempt a reconnect a second later. The constant switching of active orgs combined with reconnect seems to work.
Environment
System:
OS: Linux 6.17 Arch Linux
CPU: (16) x64 AMD Ryzen 7 7700X 8-Core Processor
Memory: 20.08 GB / 30.47 GB
Container: Yes
Shell: 4.1.2 - /usr/bin/fish
Binaries:
Node: 25.1.0 - /usr/bin/node
Yarn: 1.22.22 - /usr/bin/yarn
npm: 11.6.2 - /usr/bin/npm
pnpm: 10.20.0 - /usr/bin/pnpm
Browsers:
Chromium: 142.0.7444.134
Firefox: 144.0.2
Firefox Developer Edition: 144.0.2
npmPackages:
@clerk/backend: ^2.20.0 => 2.20.0
@clerk/clerk-react: ^5.53.8 => 5.53.8
@cloudflare/vite-plugin: ^1.14.0 => 1.14.0
@eslint/js: ^9.33.0 => 9.39.1
@types/react: ^19.1.10 => 19.2.2
@types/react-dom: ^19.1.7 => 19.2.2
@vitejs/plugin-react: ^5.0.0 => 5.1.0
eslint: ^9.33.0 => 9.39.1
eslint-plugin-react-hooks: ^5.2.0 => 5.2.0
eslint-plugin-react-refresh: ^0.4.20 => 0.4.24
globals: ^16.3.0 => 16.5.0
react: ^19.1.1 => 19.2.0
react-dom: ^19.1.1 => 19.2.0
typescript: ~5.8.3 => 5.8.3
typescript-eslint: ^8.39.1 => 8.46.3
vite: ^7.1.2 => 7.2.2
wrangler: ^4.46.0 => 4.46.0
Preliminary Checks
I have reviewed the documentation: https://clerk.com/docs
I have searched for existing issues: https://github.com/clerk/javascript/issues
I have not already reached out to Clerk support via email or Discord (if you have, no need to open an issue here)
This issue is not a question, general help request, or anything other than a bug report directly related to Clerk. Please ask questions in our Discord community: https://clerk.com/discord.
Reproduction
https://github.com/wuzzeb/clerk-auth-org
Publishable key
pk_test_ZmFzdC1iZWV0bGUtMjQuY2xlcmsuYWNjb3VudHMuZGV2JA
Description
High Level Picture: I have users in orgs and a user might be in more than one org. While most users will be in a single org, a consultant user will be a member of multiple orgs and will wish to have multiple orgs open in different tabs (so not a single active org, but different orgs they are consulting with).
I want to provide pages for each org like explained in https://clerk.com/docs/guides/organizations/org-slugs-in-urls . So I have a react single page application that on the client has a url such as
https://domain/{orgId}/projectsfor example. The client extracts the{orgId}from the route and then makes a websocket connection towss://domain/api/websocket/{orgId}. I now want to use authenticateRequest in the websocket handler on the server (cloudflare worker) to check if the user can access the org. Because it is websockets, the auth has to go through the cookies since can't set a bearer token.Full Example Repository
I made an example github repository https://github.com/wuzzeb/clerk-auth-org I am using to test by starting with a fresh repository and adding only a little bit of code. See wuzzeb/clerk-auth-org@69e1582 for the code above the default scaffold. But below I post some snippets and my investigation of what the backend code is doing:
Server Code
On the server, the code looks like
Results
authenticateRequestworks, everything matches.authenticateReqeustWithTokenInCookiejavascript/packages/backend/src/tokens/request.ts
Line 434 in 539fad7
handleMaybeOrganizationSyncHandshakeatjavascript/packages/backend/src/tokens/request.ts
Line 596 in 539fad7
handleMaybeOrganizationSyncHandshakecorrectly detects that a org switch is needed at line 378javascript/packages/backend/src/tokens/request.ts
Line 378 in 539fad7
handleMaybeHandshakeStatusis calledjavascript/packages/backend/src/tokens/request.ts
Line 399 in 539fad7
handleMaybeHandshakeStatusdoes is callisRequestEligibleForHandshakejavascript/packages/backend/src/tokens/request.ts
Line 317 in 539fad7
isRequestEligibleForHandshakechecks for the Sec-Fetch-Mode headerjavascript/packages/backend/src/tokens/handshake.ts
Line 107 in 539fad7
Sec-Fetch-Modeis empty because this is a websocket requestisRequestEligibleForHandshakereturns false, causinghandleMaybeHandshakeStatusto returnsignedOuthandleMaybeOrganizationSyncHandshakereturns null,javascript/packages/backend/src/tokens/request.ts
Line 405 in 539fad7
Why is that sec-fetch-req check there in the first place? I don't understand
isRequestEligibleForHandshake.Workaround?
On the client, right before making the websocket connection, I can call
This works except if you have multiple tabs open there is a small timing window where things go wrong. I use ReconnectingWebsocket in that case and it seems to work. If two tabs are trying to connect at the same time, one will fail because it gets the wrong org, but then it will attempt a reconnect a second later. The constant switching of active orgs combined with reconnect seems to work.
Environment
System: OS: Linux 6.17 Arch Linux CPU: (16) x64 AMD Ryzen 7 7700X 8-Core Processor Memory: 20.08 GB / 30.47 GB Container: Yes Shell: 4.1.2 - /usr/bin/fish Binaries: Node: 25.1.0 - /usr/bin/node Yarn: 1.22.22 - /usr/bin/yarn npm: 11.6.2 - /usr/bin/npm pnpm: 10.20.0 - /usr/bin/pnpm Browsers: Chromium: 142.0.7444.134 Firefox: 144.0.2 Firefox Developer Edition: 144.0.2 npmPackages: @clerk/backend: ^2.20.0 => 2.20.0 @clerk/clerk-react: ^5.53.8 => 5.53.8 @cloudflare/vite-plugin: ^1.14.0 => 1.14.0 @eslint/js: ^9.33.0 => 9.39.1 @types/react: ^19.1.10 => 19.2.2 @types/react-dom: ^19.1.7 => 19.2.2 @vitejs/plugin-react: ^5.0.0 => 5.1.0 eslint: ^9.33.0 => 9.39.1 eslint-plugin-react-hooks: ^5.2.0 => 5.2.0 eslint-plugin-react-refresh: ^0.4.20 => 0.4.24 globals: ^16.3.0 => 16.5.0 react: ^19.1.1 => 19.2.0 react-dom: ^19.1.1 => 19.2.0 typescript: ~5.8.3 => 5.8.3 typescript-eslint: ^8.39.1 => 8.46.3 vite: ^7.1.2 => 7.2.2 wrangler: ^4.46.0 => 4.46.0