Skip to content

Authenticate with Clerk

Clerk issues short-lived RS256 session tokens signed by your instance. This recipe verifies one in the serveMcpHttp auth.authenticate hook and maps its claims to the framework’s minimal Principal.

The generic JWKS mechanics — bearer parsing, the 401-versus-500 split, and the offline local-JWKS test approach — live in the JWT bearer recipe. This page covers only what is specific to Clerk: the instance JWKS location, the azp check, and the session claim shape.

  1. Point key resolution at the instance JWKS

    Clerk publishes its public keys at your Frontend API URL with /.well-known/jwks.json appended. That URL is https://<slug>.clerk.accounts.dev in development and https://clerk.<your-domain>.com for a production custom domain — the same value Clerk puts in the token’s iss claim.

    export function clerkJwksUrl(frontendApiUrl: string): URL {
    const base = new URL(frontendApiUrl);
    if (base.protocol !== "https:") {
    throw new Error("The Clerk Frontend API URL must use HTTPS.");
    }
    return new URL("/.well-known/jwks.json", base.origin);
    }
    export function createClerkRemoteKeys(
    frontendApiUrl: string,
    options: { readonly timeoutMs?: number } = {},
    ): JWTVerifyGetKey {
    return createRemoteJWKSet(clerkJwksUrl(frontendApiUrl), {
    timeoutDuration: options.timeoutMs ?? 5_000,
    cooldownDuration: 30_000,
    cacheMaxAge: 600_000,
    });
    }

    Keeping key resolution a parameter is what makes the verifier testable: the composition root passes createClerkRemoteKeys(...), and the tests pass createLocalJWKSet(...) so signature verification stays real with no network access. Verification needs no Clerk Secret Key, because a JWKS holds only public keys.

  2. Verify the signature, issuer, and lifetime

    const deadline = AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]);
    let payload: JWTPayload;
    try {
    const verified = await settleWith(
    deadline,
    jwtVerify(token, keys, {
    issuer,
    algorithms: ["RS256"],
    // Clerk session tokens carry no aud; azp is the party binding.
    // These claims are always minted, so a signed token missing one
    // — notably exp — must not become a permanent credential.
    requiredClaims: ["sub", "iss", "exp"],
    clockTolerance,
    }),
    );
    payload = verified.payload;
    } catch (error) {
    if (isInvalidCredential(error)) return null;
    throw new ClerkVerificationUnavailableError();
    }

    issuer is the Frontend API URL’s origin. jwtVerify validates exp and nbf, and requiredClaims makes the expiry mandatory rather than validated-when-present; the clock tolerance matches the skew allowance Clerk’s own backend SDK applies. The hook’s signal and the verifier’s own timeout together bound the JWKS fetch.

  3. Split invalid credentials from an unavailable verifier

    const INVALID_CREDENTIAL_CODES: ReadonlySet<string> = new Set([
    errors.JOSEAlgNotAllowed.code,
    errors.JWKSNoMatchingKey.code,
    errors.JWSInvalid.code,
    errors.JWSSignatureVerificationFailed.code,
    errors.JWTClaimValidationFailed.code,
    errors.JWTExpired.code,
    errors.JWTInvalid.code,
    ]);

    An expired token, a rotated-away kid, or a foreign signature is an invalid credential and becomes null, then HTTP 401. A JWKS timeout, an unreachable Frontend API, or an ambiguous key set (JWKSMultipleMatchingKeys — the instance’s key-publication problem, not the caller’s) is not in the set, so it throws and becomes a sanitized HTTP 500. ClerkVerificationUnavailableError carries a fixed sentence and no cause, because a jose claim-validation failure holds the decoded payload and none of it may reach a log.

  4. Enforce the session status and the authorized party

    const { azp, sid, sts } = payload;
    if (sts !== undefined && sts !== "active") return null;
    if (azp === undefined) {
    if (requireAuthorizedParty) return null;
    } else {
    if (typeof azp !== "string" || azp === "") return null;
    if (!authorizedParties.includes(azp)) return null;
    }
    const subject = payload.sub;
    if (typeof subject !== "string" || subject === "") return null;

    Session token v2 carries sts. A user who signed in but has unfinished session tasks — MFA enrollment, a mandatory organization selection — holds a validly signed token with sts: "pending", and Clerk’s own SDKs treat pending as signed out by default; rejecting it server-side closes the same door here.

    azp names the origin the token was minted for. Clerk recommends an explicit allowlist, because a token issued to another origin of the same instance is otherwise a perfectly valid signature here. By default the check applies when the claim is present — custom JWT templates and machine tokens omit it, and such a token passes the allowlist unchecked. When every legitimate caller is a session, set requireAuthorizedParty: true (the example’s composition root does, since it already demands the allowlist) so azp-less tokens are rejected too.

  5. Read the organization from the v2 compact claims

    function readOrganization(
    payload: JWTPayload,
    ): { readonly orgId?: string; readonly orgRole?: string } | null {
    const compact = payload.o;
    if (compact !== undefined) {
    if (typeof compact !== "object" || compact === null) return null;
    const { id, rol } = compact as {
    readonly id?: unknown;
    readonly rol?: unknown;
    };
    if (!isAbsentOrNonEmptyString(id)) return null;
    if (!isAbsentOrNonEmptyString(rol)) return null;
    return {
    ...(id === undefined ? {} : { orgId: id }),
    ...(rol === undefined ? {} : { orgRole: rol }),
    };
    }
    const { org_id: orgId, org_role: orgRole } = payload;
    if (!isAbsentOrNonEmptyString(orgId)) return null;
    if (!isAbsentOrNonEmptyString(orgRole)) return null;
    return {
    ...(orgId === undefined ? {} : { orgId }),
    ...(orgRole === undefined
    ? {}
    : { orgRole: orgRole.replace(/^org:/u, "") }),
    };
    }

    Session token v2 — the current standard — moved the organization into the compact top-level o object: o.id, o.rol (without the v1 org: prefix), o.slg, and permission data. The v1 org_id/org_role claims remain a fallback for unmigrated instances, with the role normalized to the v2 form so an access rule sees one stable shape either way.

  6. Map the verified claims to a Principal

    export function toPrincipal(claims: ClerkSessionClaims): Principal {
    const attributes: Record<string, unknown> = {};
    if (claims.sid !== undefined) attributes.sessionId = claims.sid;
    if (claims.org_id !== undefined) attributes.organizationId = claims.org_id;
    if (claims.org_role !== undefined) {
    attributes.organizationRole = claims.org_role;
    }
    return Object.freeze({
    id: claims.sub,
    attributes: Object.freeze(attributes),
    });
    }

    sub is the Clerk user ID and becomes Principal.id. sid, org_id, and org_role describe the session and the active organization membership, so they are the claims an access rule can decide on. Nothing else crosses: not the token, not the full claim set, not a Clerk SDK object.

  7. Wire the hook at the composition root

    export function createClerkAuthenticate(
    verifier: ClerkSessionVerifier,
    ): (request: McpHttpAuthenticationRequest) => Promise<Principal | null> {
    return async (request) => {
    const token = readBearerToken(request.headers);
    if (token === null) return null;
    const claims = await verifier.verify(token, { signal: request.signal });
    return claims === null ? null : toPrincipal(claims);
    };
    }
    export async function startClerkMcpHttp(
    options: ClerkMcpHttpOptions,
    ): Promise<McpHttpServerHandle> {
    return serveMcpHttp(engine, {
    ...(options.port === undefined ? {} : { port: options.port }),
    auth: {
    mode: "required",
    authenticate: createClerkAuthenticate(options.verifier),
    },
    });
    }

    The example refuses to start without CLERK_AUTHORIZED_PARTIES, so an allowlist can never be forgotten into an accept-everything default.

  8. Read the identity inside a capability

    access: "authenticated",
    async run({ context }) {
    const { principal } = context;
    if (principal === null) {
    throw new EngineError({
    code: "UNAUTHENTICATED",
    message: "The request has no verified identity.",
    });
    }
    return {
    principalId: principal.id,
    attributes: { ...principal.attributes },
    };
    },

    identity.whoami derives its whole result from context.principal and ignores its input, so calling it tells you exactly which identity the boundary proved. A capability that must also decide what that identity may do — for instance from organizationRole — uses a function access rule instead.

The verifier is one function, so Clerk’s own backend SDK drops into the same place. This variant is not part of the runnable example, which stays on jose:

// Provider-SDK variant of the verifier above.
import { verifyToken } from "@clerk/backend";
const claims = await verifyToken(token, {
secretKey: process.env.CLERK_SECRET_KEY,
authorizedParties: ["https://app.example.com"],
});

verifyToken performs the same azp, exp, and nbf checks and accepts jwtKey for networkless verification and clockSkewInMs for the tolerance. In a host that already has a Request object, authenticateRequest() is the higher-level entry point Clerk recommends; the hook then maps its resolved auth object to Principal exactly as toPrincipal does above.

Whichever you choose, keep it at the composition root. The capability sees only Principal.

Build the repository, then start the boundary against your Clerk instance:

Terminal window
yarn build
CLERK_FRONTEND_API_URL="https://clean-mayfly-62.clerk.accounts.dev" \
CLERK_AUTHORIZED_PARTIES="http://localhost:3000,https://app.example.com" \
PORT=3010 \
yarn workspace @invokta/example-auth-clerk mcp:http

Copy a session token from a signed-in page by running await window.Clerk.session.getToken() in the browser console, then call the tool:

Terminal window
curl -sS http://127.0.0.1:3010/mcp \
-H "authorization: Bearer $CLERK_SESSION_TOKEN" \
-H 'accept: application/json, text/event-stream' \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":"1","method":"tools/call","params":{"name":"identity_whoami","arguments":{}}}'

The result reports the Clerk user ID and the session and organization attributes. Clerk session tokens expire in about a minute, so a stale token returns HTTP 401 — and so does a token whose azp is missing from CLERK_AUTHORIZED_PARTIES.

Terminal window
yarn workspace @invokta/example-auth-clerk test

The tests mint tokens in Clerk’s session token v2 claim shape — with a v1 fallback case — from a locally generated key pair and resolve them through a local JWKS, so signature verification is real while no test touches the network or needs a Clerk account. They cover a valid token, a pending session, an expired one, a token without an expiry, a foreign issuer, a foreign signature, a rotated kid, an unlisted azp, an azp-less token in both modes, an algorithm outside the allowlist, a malformed credential, an unavailable verifier, an ambiguous key set, and the absence of any token material in the produced principal.

Read the complete auth-clerk-engine example for the verifier, the claim mapping, the hook wiring, and those tests.

Next: Authorize with capability access rules for the policy half, and Integrating an identity provider at the HTTP boundary for the normative hook contract and the secret rules.