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.
Verify a Clerk session token
Section titled “Verify a Clerk session token”-
Point key resolution at the instance JWKS
Clerk publishes its public keys at your Frontend API URL with
/.well-known/jwks.jsonappended. That URL ishttps://<slug>.clerk.accounts.devin development andhttps://clerk.<your-domain>.comfor a production custom domain — the same value Clerk puts in the token’sissclaim.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 passcreateLocalJWKSet(...)so signature verification stays real with no network access. Verification needs no Clerk Secret Key, because a JWKS holds only public keys. -
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();}issueris the Frontend API URL’s origin.jwtVerifyvalidatesexpandnbf, andrequiredClaimsmakes the expiry mandatory rather than validated-when-present; the clock tolerance matches the skew allowance Clerk’s own backend SDK applies. The hook’ssignaland the verifier’s own timeout together bound the JWKS fetch. -
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 becomesnull, 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.ClerkVerificationUnavailableErrorcarries a fixed sentence and no cause, because a jose claim-validation failure holds the decoded payload and none of it may reach a log. -
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 withsts: "pending", and Clerk’s own SDKs treat pending as signed out by default; rejecting it server-side closes the same door here.azpnames 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, setrequireAuthorizedParty: true(the example’s composition root does, since it already demands the allowlist) so azp-less tokens are rejected too. -
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
oobject:o.id,o.rol(without the v1org:prefix),o.slg, and permission data. The v1org_id/org_roleclaims remain a fallback for unmigrated instances, with the role normalized to the v2 form so an access rule sees one stable shape either way. -
Map the verified claims to a
Principalexport 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),});}subis the Clerk user ID and becomesPrincipal.id.sid,org_id, andorg_roledescribe 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. -
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. -
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.whoamiderives its whole result fromcontext.principaland 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 fromorganizationRole— uses a function access rule instead.
Using @clerk/backend instead
Section titled “Using @clerk/backend 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.
Run it
Section titled “Run it”Build the repository, then start the boundary against your Clerk instance:
yarn buildCLERK_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:httpCopy a session token from a signed-in page by running
await window.Clerk.session.getToken() in the browser console, then call the
tool:
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.
Verify it
Section titled “Verify it”yarn workspace @invokta/example-auth-clerk testThe 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.