Skip to content

Authenticate with WorkOS AuthKit

WorkOS AuthKit issues a JWT access token signed by a JWKS scoped to your environment’s client id. The engine verifies that token in the authenticate hook and maps its claims to a Principal; the organization claim is what a multi-tenant access rule keys on.

This recipe covers only the WorkOS specifics. The generic JWKS mechanics — bearer parsing, the 401 versus 500 split, and the offline local-JWKS test approach — live in the JWT bearer recipe.

  1. Point key resolution at your WorkOS client

    export const WORKOS_DEFAULT_AUTH_DOMAIN = "https://api.workos.com";
    export const WORKOS_DEFAULT_ISSUER = "https://api.workos.com/";
    export function workOsJwksUrl(
    clientId: string,
    authDomain: string = WORKOS_DEFAULT_AUTH_DOMAIN,
    ): URL {
    if (clientId.trim() === "") {
    throw new Error("A WorkOS client id is required to derive the JWKS URL.");
    }
    const base = authDomain.endsWith("/") ? authDomain : `${authDomain}/`;
    return new URL(`sso/jwks/${encodeURIComponent(clientId)}`, base);
    }

    WorkOS publishes the signing keys at sso/jwks/<clientId> and issues tokens with iss set to https://api.workos.com/. When the environment uses a custom auth domain, both values move to that domain, so the verifier accepts an explicit issuer and JWKS URL.

  2. Verify the access token with injectable key resolution

    const keys =
    options.keys ??
    createRemoteJWKSet(
    options.jwksUrl === undefined
    ? workOsJwksUrl(options.clientId)
    : new URL(options.jwksUrl),
    { timeoutDuration: timeoutMs },
    );
    const verified = await withDeadline(
    jwtVerify(token, keys, {
    issuer,
    algorithms,
    clockTolerance,
    ...(options.audience === undefined
    ? {}
    : { audience: options.audience }),
    }),
    deadline,
    );

    Production wiring resolves the remote JWKS; tests inject createLocalJWKSet, so signature verification is real and offline. deadline is AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]), which bounds the verifier’s own wait and observes the request signal.

  3. Separate an invalid credential from an unavailable check

    function isInvalidCredential(error: unknown): boolean {
    return (
    error instanceof errors.JWTExpired ||
    error instanceof errors.JWTClaimValidationFailed ||
    error instanceof errors.JWTInvalid ||
    error instanceof errors.JWSInvalid ||
    error instanceof errors.JWSSignatureVerificationFailed ||
    error instanceof errors.JOSEAlgNotAllowed ||
    error instanceof errors.JWKSNoMatchingKey ||
    error instanceof errors.JWKSMultipleMatchingKeys
    );
    }

    Only these prove the credential itself is unusable, so only these become null. A JWKS timeout, a network fault, or any unknown failure throws WorkOsVerificationUnavailableError, which carries no message detail and no cause so token material cannot reach a log or an HTTP 500 body.

  4. Map the WorkOS claims to a principal

    export function toPrincipal(claims: WorkOsAccessTokenClaims): Principal {
    const attributes = {
    ...(claims.sid === undefined ? {} : { sid: claims.sid }),
    ...(claims.org_id === undefined ? {} : { org_id: claims.org_id }),
    ...(claims.role === undefined ? {} : { role: claims.role }),
    ...(claims.permissions === undefined
    ? {}
    : { permissions: Object.freeze([...claims.permissions]) }),
    };
    return Object.keys(attributes).length === 0
    ? Object.freeze({ id: claims.sub })
    : Object.freeze({ id: claims.sub, attributes: Object.freeze(attributes) });
    }

    An AuthKit access token carries sub (the WorkOS user id), sid (the session id), and — when the user selected an organization — org_id, role, and permissions. The verifier narrows the payload to exactly those claims before mapping, so nothing else the token carries can reach the principal.

  5. Wire the hook into the adapter

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

    WorkOS issues two token flavors with different issuers and key sets, and the composition root must pick one:

    • Session access tokens — the ones an application holding a WorkOS session already has. Their issuer is https://api.workos.com/ (or the custom auth domain), the JWKS is sso/jwks/<clientId>, and they carry no aud claim, so audience validation would reject every one of them. This is the example’s default flavor.
    • MCP OAuth tokens — obtained through OAuth resource indicators when AuthKit acts as the authorization server for this engine. Register the engine’s /mcp URL as a resource in WorkOS; the issuer, authorization server, and JWKS then all live on the environment’s AuthKit domain (https://<environment>.authkit.app, JWKS at /oauth2/jwks), aud is bound to the registered resource and must be validated, and the same value is published as resourceMetadata.resource. The example selects this flavor when WORKOS_MCP_RESOURCE is set, and then requires WORKOS_AUTHKIT_DOMAIN — with session-flavor defaults, both token flavors would be answered 401 and discovery would advertise an authorization server that serves no metadata.
  6. Let capabilities read the verified identity only

    export const whoami = defineCapability({
    title: "Who am I",
    description: "Return the verified identity of the current caller.",
    input: z.object({}),
    output: z.object({
    principalId: z.string(),
    attributes: z.record(z.string(), z.unknown()),
    }),
    access: "authenticated",
    async run({ context }) {
    const { principal } = context;
    if (principal === null) {
    throw new EngineError({
    code: "UNAUTHENTICATED",
    message: "An authenticated principal is required.",
    });
    }
    return {
    principalId: principal.id,
    attributes: { ...principal.attributes },
    };
    },
    });

    The hook proves who the caller is; the capability’s access rule decides what that caller may do. A tenant-scoped capability replaces access: "authenticated" with a function that keys on principal.attributes.org_id, the verified organization claim, so a caller cannot select another tenant through capability input. See Authorize with domain data for a fail-closed permission port built on principal attributes.

Build the repository, then start the engine in the session-token flavor — verifying the access tokens your application already holds:

Terminal window
yarn build
WORKOS_CLIENT_ID=client_... \
PORT=3000 \
yarn workspace @invokta/example-auth-workos mcp:http

WORKOS_CLIENT_ID selects the JWKS and is the only required variable; no API key or client secret is needed to verify a token. WORKOS_ISSUER and WORKOS_JWKS_URL override the defaults for a custom auth domain. Note what this flavor does not give you: session tokens carry no aud, so any valid session token from the environment — including one minted for your main web app — authenticates here.

Call the engine with an AuthKit access token your application already holds:

Terminal window
curl -sS http://127.0.0.1:3000/mcp \
-H "authorization: Bearer $WORKOS_ACCESS_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 echoes the verified identity, including org_id. Dropping the header answers HTTP 401 before engine.invoke runs.

For the MCP OAuth flavor — tokens bound to this engine through a resource indicator, with Protected Resource Metadata published for OAuth-capable MCP clients — register the engine’s URL as a resource in WorkOS and start with the environment’s AuthKit domain:

Terminal window
WORKOS_CLIENT_ID=client_... \
WORKOS_AUTHKIT_DOMAIN=https://your-environment.authkit.app \
WORKOS_MCP_RESOURCE=https://engine.example.com/mcp \
WORKOS_MCP_CHALLENGE_SCOPES="engine:invoke" \
PORT=3000 \
yarn workspace @invokta/example-auth-workos mcp:http

The verifier then expects the AuthKit domain as iss, resolves keys from its /oauth2/jwks document, validates aud against the registered resource, and the engine refuses to start if WORKOS_MCP_RESOURCE is set without an issuer source.

WORKOS_MCP_CHALLENGE_SCOPES is optional and belongs to the OAuth flavor only: it serializes the ordered base scopes into the 401 Bearer challenge so a client asks AuthKit for them on its first authorization request. Naming it without WORKOS_MCP_RESOURCE fails at startup, because the session-token flavor publishes no Protected Resource Metadata to attach the scopes to.

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

The tests mint RS256 tokens in the AuthKit claim shape from a locally generated key pair and assert that a valid token yields the expected principal, that every invalid class — including a token without an expiry and one signed outside the algorithm allowlist — resolves to null, that an unavailable JWKS or an ambiguous key set rejects instead, that both composition-root flavors and the fail-fast case wire the expected issuer, JWKS, audience, and resource metadata, and that no token material reaches the principal.

Read the complete auth-workos-engine example for the verifier, the claims mapping, and the full test matrix.