Authenticate with a JWT bearer token
Any identity provider that issues JWT access tokens and publishes a JWKS plugs
into Invokta the same way: the serveMcpHttp authenticate hook verifies the
token, maps a few verified claims to a Principal, and hands it to
engine.invoke. Nothing about the provider reaches a capability.
This is the base authentication recipe. The provider-specific recipes change only the issuer, the audience, and the claim names — the mechanics below stay the same.
Build the integration
Section titled “Build the integration”-
Map verified claims to a
Principalexport function toPrincipal(claims: VerifiedAccessTokenClaims,): Principal | null {const subject = claims.sub;if (typeof subject !== "string" || subject.trim() === "") return null;const issuer = claims.iss;if (typeof issuer !== "string" || issuer === "") return null;const clientId = claims.client_id;const attributes: JwtPrincipalAttributes = {scopes: parseScopeClaim(claims.scope),issuer,...(typeof clientId === "string" && clientId !== ""? { clientId }: {}),};return Object.freeze({id: subject,attributes: Object.freeze({ ...attributes }),});}The claim allowlist is the whole point.
subbecomesPrincipal.id;scope,iss, andclient_idbecome attributes an access rule can read. Every other claim — email, name, provider metadata, the raw token — stays out. A claim set that cannot identify a subject returnsnull, which the caller turns into an invalid credential, never into an anonymous principal. -
Parse the
scopeclaim defensivelyexport function parseScopeClaim(value: unknown): ReadonlyArray<string> {if (typeof value !== "string") return Object.freeze([]);const scopes = value.split(/\s+/u).filter((scope) => scope !== "");return Object.freeze([...new Set(scopes)]);}RFC 6749 defines
scopeas a space-delimited string, and RFC 9068 keeps that form for JWT access tokens. Any other shape is treated as “no scopes” rather than guessed at, so a malformed claim can never widen authority. -
Verify the token with an injectable key source
export function createAccessTokenVerifier(options: AccessTokenVerifierOptions,): AccessTokenVerifier {const verifyOptions: JWTVerifyOptions = {issuer: options.issuer,audience: options.audience,algorithms: [...(options.algorithms ?? defaultSignatureAlgorithms)],requiredClaims: ["sub", "iss", "aud", "exp"],...(options.clockToleranceSeconds === undefined? {}: { clockTolerance: options.clockToleranceSeconds }),};return {async verify(token, { signal }) {if (signal.aborted) {throw new AccessTokenVerificationUnavailableError();}// ... abort wiring omitted; see the exampletry {const { payload } = await Promise.race([jwtVerify(token, options.getKey, verifyOptions),cancellation,]);return toPrincipal(payload);} catch (error) {if (isInvalidCredential(error)) return null;throw new AccessTokenVerificationUnavailableError();} finally {removeAbortListener();}},};}getKeyis aJWTVerifyGetKey, so the key source is a parameter rather than a hard-coded fetch. Every check the security policy depends on lives here: signature, algorithm allowlist, issuer, audience, expiry, and the claims the mapping needs. The framework defines none of them. -
Separate an invalid credential from a broken check
const invalidCredentialCodes: ReadonlySet<string> = new Set([errors.JOSEAlgNotAllowed.code,errors.JOSENotSupported.code,errors.JWKSNoMatchingKey.code,errors.JWSInvalid.code,errors.JWSSignatureVerificationFailed.code,errors.JWTClaimValidationFailed.code,errors.JWTExpired.code,errors.JWTInvalid.code,]);function isInvalidCredential(error: unknown): boolean {return (error instanceof errors.JOSEError &&invalidCredentialCodes.has(error.code));}These jose codes mean the caller’s token is not acceptable, so
verifyresolvesnull. A JWKS timeout, an unusable key set, or an unexpected failure means the check never happened, soverifyrejects instead. The rejection carries a fixed sentence and no cause, so no token, URL, or provider response can reach a log through it.JWKSMultipleMatchingKeysstays out of the invalid set on purpose. Two same-algorithm keys published withoutkidheaders is the issuer’s key-publication problem, not evidence against the credential, and treating it as 401 would silently reject every legitimate token during a kid-less key rotation. This example reports the honest 500; jose also documents a candidate-retry iteration on that error for deployments that must tolerate such issuers. -
Read the Bearer credential
export function readBearerToken(headers: McpHttpHeaderView): string | null {const authorization = headers.get("authorization");if (authorization === null) return null;const match = /^Bearer (\S+)$/iu.exec(authorization);return match?.[1] ?? null;}The adapter rejects a request carrying more than one raw
Authorizationheader before the hook runs, so exactly one value is possible here. Anything that is not a single non-whitespace token after the scheme is treated as no credential at all. -
Wire the hook
export function createJwtBearerAuthenticate(verifier: AccessTokenVerifier,): (request: McpHttpAuthenticationRequest) => Promise<Principal | null> {return async (request) => {const token = readBearerToken(request.headers);if (token === null) return null;return verifier.verify(token, { signal: request.signal });};}await serveMcpHttp(engine, {host,port,auth: {mode: "required",authenticate: createJwtBearerAuthenticate(verifier),},});The request signal is handed to the verifier, so its I/O ends when the caller disconnects. Three outcomes, three HTTP results: a principal continues to
engine.invoke,nullbecomes 401, and a rejection becomes a sanitized 500. -
Resolve the key set at the composition root
const jwksUri = resolveJwksUri(issuer, process.env.AUTH_JWT_JWKS_URI);const getKey = createRemoteJWKSet(new URL(jwksUri), {timeoutDuration: defaultJwksTimeoutMs,cooldownDuration: 30_000,cacheMaxAge: 600_000,});createRemoteJWKSetcaches keys and bounds its own I/O, which is the verifier’s timeout obligation.resolveJwksUrifalls back to<issuer>/.well-known/jwks.json— a widespread convention, not a specification requirement. The authoritative location is thejwks_urimember of<issuer>/.well-known/openid-configuration; read it once and setAUTH_JWT_JWKS_URIwhen your provider hosts its key set elsewhere. The override is held to the same HTTPS-or-loopback rule as the issuer: a key set fetched over plaintext HTTP could be replaced by an on-path attacker, and a substituted key turns into accepted attacker-minted tokens. -
Keep the capability free of identity plumbing
export const whoami = defineCapability({title: "Who am I",description: "Return the verified identity of the current caller.",input,output,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 ?? {}) },};},});access: "authenticated"closes the capability on every channel, andrunreadscontext.principalonly. Input cannot name, widen, or override an identity.
Run it
Section titled “Run it”Build the repository, then start the engine against your provider:
yarn buildAUTH_JWT_ISSUER="https://your-tenant.example.com/" \AUTH_JWT_AUDIENCE="http://127.0.0.1:3000/mcp" \PORT=3000 \node examples/auth-jwt-bearer-engine/dist/mcp-http.jsCall it with an access token your provider issued for that audience:
curl -s http://127.0.0.1:3000/mcp \ -H "authorization: Bearer $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 same request without the header returns HTTP 401 with a WWW-Authenticate: Bearer challenge, and engine.invoke is never reached. To make that challenge
carry a discovery pointer, follow
Discover the Authorization Server.
Verify it
Section titled “Verify it”The example mints tokens from a key pair generated inside the test process and
serves them through createLocalJWKSet, so signature verification is real and
nothing touches the network:
yarn workspace @invokta/example-auth-jwt-bearer testThe suite covers the valid credential, every invalid class (missing header, wrong scheme, malformed, expired, wrong issuer, wrong audience, unknown key, bad signature, algorithm outside the allowlist, missing subject), the infrastructure-failure rejection, cancellation, and the assertion that no token material appears in the produced principal.
Read the complete
auth-jwt-bearer-engine
example for the verifier, the claim mapping, the composition root, and the
tests.
- Authorize with capability access rules turns the verified scopes into a policy decision.
- HTTP authentication is the normative contract for the hook, the failure semantics, and the secret and logging rules.