Skip to content

authenticateRequest and organizationSyncOptions #7178

@wuzzeb

Description

@wuzzeb

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

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-triageA ticket that needs to be triaged by a team member

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions