Authenticate with AWS Cognito
An Amazon Cognito user pool is a JWKS issuer, so it plugs into the same
auth.authenticate hook as any other JWT bearer provider. What makes Cognito
different is the shape of its access token: it carries no aud claim, it
names the calling app client in client_id, and it labels itself with
token_use.
This recipe covers only those Cognito specifics. The generic mechanics — bearer parsing, JWKS caching, and the 401-versus-500 split — live in the JWT bearer recipe.
What a Cognito user pool gives you
Section titled “What a Cognito user pool gives you”| Value | Shape |
|---|---|
Issuer (iss) |
https://cognito-idp.<region>.amazonaws.com/<user-pool-id> |
| JWKS | https://cognito-idp.<region>.amazonaws.com/<user-pool-id>/.well-known/jwks.json |
| Algorithm | RS256 |
| Subject | sub |
| App client | client_id on an access token (an id token uses aud) |
| Token kind | token_use: access or id |
| Scopes | scope, one space-delimited string |
| Groups | cognito:groups, an array of strings |
Build the verifier
Section titled “Build the verifier”-
Derive the pool endpoints
export function cognitoIssuer(region: string, userPoolId: string): string {return `https://cognito-idp.${region}.amazonaws.com/${userPoolId}`;}export function cognitoJwksUri(region: string, userPoolId: string): URL {return new URL(`${cognitoIssuer(region, userPoolId)}/.well-known/jwks.json`);}Both are public. No AWS credential and no client secret are needed to verify a token.
-
Declare the verified identity
export interface CognitoVerifiedIdentity {readonly subject: string;readonly clientId: string;readonly scopes: ReadonlyArray<string>;readonly groups: ReadonlyArray<string>;}This is the whole surface the engine gets. The token, its header, and every unverified claim stop at the composition root.
-
Make key resolution injectable
const getKey: JWTVerifyGetKey =config.getKey ??createRemoteJWKSet(cognitoJwksUri(config.region, config.userPoolId), {timeoutDuration: config.jwksTimeoutMs ?? DEFAULT_JWKS_TIMEOUT_MS,});Production wiring omits
getKeyand gets a cached remote JWKS bounded by its own timeout, three seconds by default. Tests passcreateLocalJWKSet(...), so signature verification is real with no network access. -
Verify signature, issuer, and algorithm
try {const verified = await withAbort(jwtVerify(token, getKey, {issuer,algorithms: ["RS256"],// Cognito access tokens carry no aud claim; client_id is checked// in toIdentity instead. The other claims are always minted, so a// signed token missing one is not a Cognito access token.requiredClaims: ["sub", "iss", "exp"],}),options.signal,);payload = verified.payload;} catch (error) {if (isInvalidCredential(error)) return null;// Nothing from the failure is re-exported: no message, no cause.throw new CognitoVerificationUnavailableError();}isInvalidCredentialmatches thejoseerror classes that mean “this credential is unusable” —JWTExpired,JWTClaimValidationFailed,JWSSignatureVerificationFailed,JWKSNoMatchingKey, and their siblings. Anything else, including a JWKS timeout, an aborted request, or an ambiguous key set (JWKSMultipleMatchingKeys— the pool’s key-publication problem, not the caller’s), is infrastructure and is rethrown as one sanitized error. -
Apply the Cognito claim rules
function toIdentity(payload: JWTPayload,appClientIds: ReadonlyArray<string>,): CognitoVerifiedIdentity | null {if (payload.token_use !== "access") return null;const subject = readStringClaim(payload, "sub");if (subject === null) return null;// An access token has no `aud` claim, so the app client is authorized here.const clientId = readStringClaim(payload, "client_id");if (clientId === null || !appClientIds.includes(clientId)) return null;const scopes = readScopes(payload);const groups = readGroups(payload);if (scopes === null || groups === null) return null;return { subject, clientId, scopes, groups };}Every rejection returns
null, never a thrown error, so an unusable token ends as HTTP 401. A malformedscopeorcognito:groupsclaim also denies the request rather than being guessed at. -
Map the claims to a
Principalexport function toPrincipal(identity: CognitoVerifiedIdentity): Principal {return {id: identity.subject,attributes: {clientId: identity.clientId,scopes: [...identity.scopes],groups: [...identity.groups],},};}subbecomes the principal id,scopebecomesattributes.scopes, andcognito:groupsbecomesattributes.groups— the claims an access rule can act on. The token itself never appears. -
Wire the hook at the composition root
export function createCognitoAuthenticate(verifier: CognitoAccessTokenVerifier,): (request: McpHttpAuthenticationRequest) => Promise<Principal | null> {return async (request) => {const token = readBearerToken(request.headers);if (token === null) return null;const identity = await verifier.verify(token, { signal: request.signal });return identity === null ? null : toPrincipal(identity);};}await serveMcpHttp(engine, {port,auth: {mode: "required",authenticate: createCognitoAuthenticate(verifier),},});The hook observes
request.signal, so a disconnected client stops the verifier from waiting on the JWKS work; the underlying fetch itself is bounded by the remote key set’s owntimeoutDuration.
Provider SDK variant
Section titled “Provider SDK variant”AWS recommends
aws-jwt-verify for Node.js. It
applies the same iss, token_use, and client_id rules and caches the JWKS
for you. It is a drop-in replacement for the verifier function above, and the
example does not depend on it:
// Provider-SDK variant. Not used by the runnable example.import { CognitoJwtVerifier } from "aws-jwt-verify";import { FailedAssertionError, JwtInvalidSignatureError, JwtParseError,} from "aws-jwt-verify/error";
const cognito = CognitoJwtVerifier.create({ userPoolId: "us-east-1_ExamplePool", tokenUse: "access", clientId: "1example23456789",});
const verifier: CognitoAccessTokenVerifier = { // aws-jwt-verify does not consume an AbortSignal; the parameter stays in // the port contract so the hook-level wait remains bounded by the caller. async verify(token, _options) { try { const payload = await cognito.verify(token); return { subject: payload.sub, clientId: payload.client_id, scopes: payload.scope?.split(" ") ?? [], groups: payload["cognito:groups"] ?? [], }; } catch (error) { // Only the SDK's token-validation failures mean "invalid credential": // malformed tokens, bad signatures, and every claim assertion // (expiry, issuer, token_use, client_id, algorithm). const invalidToken = error instanceof FailedAssertionError || error instanceof JwtInvalidSignatureError || error instanceof JwtParseError; if (invalidToken) return null; // JWKS fetch or validation failures stay an infrastructure error and // reach the adapter as a sanitized HTTP 500. throw error; } },};Authorization stays in the capability
Section titled “Authorization stays in the capability”The hook only proves who called. The example’s capability derives its whole result from the trusted principal and asks for nothing more than authentication:
export const whoami = defineCapability({ title: "Describe the authenticated principal", description: "Return the verified principal id and attributes of the current request.", input: z.object({}), output: z.object({ principalId: z.string().min(1), attributes: z.record(z.string(), z.unknown()), }), access: "authenticated", annotations: { readOnly: true, destructive: false, idempotent: true, openWorld: false, }, async run({ context }) { const principal = context.principal; if (principal === null) { // Unreachable through the engine: "authenticated" is enforced before run. throw new EngineError({ code: "FORBIDDEN", message: "An authenticated principal is required.", }); } return { principalId: principal.id, attributes: { ...principal.attributes }, }; },});Turn attributes.groups or attributes.scopes into a real policy with a
function access rule, as in
Authorize with domain data. An authenticated
Cognito user that the rule denies receives FORBIDDEN, not HTTP 401.
Run it
Section titled “Run it”Build the example and start it against your user pool:
yarn workspace @invokta/example-auth-cognito build
COGNITO_REGION=us-east-1 \COGNITO_USER_POOL_ID=us-east-1_ExamplePool \COGNITO_APP_CLIENT_IDS=1example23456789 \PORT=3000 \yarn workspace @invokta/example-auth-cognito mcp:httpCOGNITO_REGION, COGNITO_USER_POOL_ID, and COGNITO_APP_CLIENT_IDS are
required; PORT defaults to 3000, and the optional COGNITO_RESOURCE_URL
publishes Protected Resource Metadata pointing at the user pool issuer. With
that resource configured, COGNITO_CHALLENGE_SCOPES adds the ordered base
scopes to the 401 challenge — Cognito writes them as
<resource-server-identifier>/<scope>, for example engine/invoke.
Call it with an access token issued to that app client:
curl -sS http://127.0.0.1:3000/mcp \ -H 'content-type: application/json' \ -H 'accept: application/json, text/event-stream' \ -H "authorization: Bearer $COGNITO_ACCESS_TOKEN" \ -d '{"jsonrpc":"2.0","id":"1","method":"tools/call", "params":{"name":"identity_whoami","arguments":{}}}'The response reports the principal built from sub, client_id, scope, and
cognito:groups. Omitting the header, passing an id token, or passing a token
from another app client returns HTTP 401.
Verify it
Section titled “Verify it”yarn workspace @invokta/example-auth-cognito testThe tests generate an RSA key pair locally, serve it through
createLocalJWKSet, and mint tokens in the Cognito access-token claim shape.
They assert a valid token maps to the expected principal, that an expired,
malformed, foreign-issuer, foreign-client_id, foreign-key, or token_use: "id" token resolves to null, that a JWKS failure rejects instead, and that
no token material reaches the principal. No test performs network I/O or needs
an AWS account.
Read the complete
auth-cognito-engine
example for the verifier, the claim mapping, and the offline test matrix.
Continue with capability authorization for the policy half, and HTTP authentication for the hook contract and the secret and logging rules.