authenticateRequest and organizationSyncOptions
Preliminary Checks
-
[x] I have reviewed the documentation: https://clerk.com/docs
-
[x] I have searched for existing issues: https://github.com/clerk/javascript/issues
-
[x] I have not already reached out to Clerk support via email or Discord (if you have, no need to open an issue here)
-
[x] 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}/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 https://github.com/wuzzeb/clerk-auth-org/commit/69e1582ef7673078000a0bb0ae0e0097b2ee024e 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
authenticateRequestworks, everything matches. - If the currently active org is a different org, I put a breakpoint there and single stepped through
- We go to
authenticateReqeustWithTokenInCookiehttps://github.com/clerk/javascript/blob/539fad7b80ed284a7add6cf8c4c45cf4c6a0a8b2/packages/backend/src/tokens/request.ts#L434 - It gets down to calling
handleMaybeOrganizationSyncHandshakeat https://github.com/clerk/javascript/blob/539fad7b80ed284a7add6cf8c4c45cf4c6a0a8b2/packages/backend/src/tokens/request.ts#L596 -
handleMaybeOrganizationSyncHandshakecorrectly detects that a org switch is needed at line 378 https://github.com/clerk/javascript/blob/539fad7b80ed284a7add6cf8c4c45cf4c6a0a8b2/packages/backend/src/tokens/request.ts#L378 -
handleMaybeHandshakeStatusis called https://github.com/clerk/javascript/blob/539fad7b80ed284a7add6cf8c4c45cf4c6a0a8b2/packages/backend/src/tokens/request.ts#L399 - The first thing
handleMaybeHandshakeStatusdoes is callisRequestEligibleForHandshakehttps://github.com/clerk/javascript/blob/539fad7b80ed284a7add6cf8c4c45cf4c6a0a8b2/packages/backend/src/tokens/request.ts#L317 -
isRequestEligibleForHandshakechecks for the Sec-Fetch-Mode header https://github.com/clerk/javascript/blob/539fad7b80ed284a7add6cf8c4c45cf4c6a0a8b2/packages/backend/src/tokens/handshake.ts#L107 -
Sec-Fetch-Modeis empty because this is a websocket request -
isRequestEligibleForHandshakereturns false, causinghandleMaybeHandshakeStatusto returnsignedOut -
handleMaybeOrganizationSyncHandshakereturns null, https://github.com/clerk/javascript/blob/539fad7b80ed284a7add6cf8c4c45cf4c6a0a8b2/packages/backend/src/tokens/request.ts#L405 - 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.
- We go to
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
I also tried to work around the check of Sec-Fetch-Dest by adding
const newHeaders = new Headers(req.headers);
newHeaders.set("Sec-Fetch-Dest", "document");
var req = new Request(req.url, {
method: "GET",
headers: newHeaders,
});
right before calling authenticateRequest. This gets past the check in isRequestElegibleForHandshake but it still doesn't work.
Why is this check for Sec-Fetch-Dest there, and can it be worked around?