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.
Build the integration
Section titled “Build the integration”-
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 withissset tohttps://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. -
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.deadlineisAbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]), which bounds the verifier’s own wait and observes the request signal. -
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 throwsWorkOsVerificationUnavailableError, which carries no message detail and no cause so token material cannot reach a log or an HTTP 500 body. -
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, andpermissions. The verifier narrows the payload to exactly those claims before mapping, so nothing else the token carries can reach the principal. -
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 issso/jwks/<clientId>, and they carry noaudclaim, 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
/mcpURL 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),audis bound to the registered resource and must be validated, and the same value is published asresourceMetadata.resource. The example selects this flavor whenWORKOS_MCP_RESOURCEis set, and then requiresWORKOS_AUTHKIT_DOMAIN— with session-flavor defaults, both token flavors would be answered 401 and discovery would advertise an authorization server that serves no metadata.
- Session access tokens — the ones an application holding a WorkOS
session already has. Their issuer is
-
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
accessrule decides what that caller may do. A tenant-scoped capability replacesaccess: "authenticated"with a function that keys onprincipal.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.
Run it
Section titled “Run it”Build the repository, then start the engine in the session-token flavor — verifying the access tokens your application already holds:
yarn buildWORKOS_CLIENT_ID=client_... \PORT=3000 \yarn workspace @invokta/example-auth-workos mcp:httpWORKOS_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:
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:
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:httpThe 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.
Verify it
Section titled “Verify it”yarn workspace @invokta/example-auth-workos testThe 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.
Related
Section titled “Related”- Capability authorization for the policy half of the boundary.
- HTTP authentication for the hook contract and the secret and logging rules.