skip to content

Your service verifies Amazon Cognito user pool JWTs itself against the pool's published JWKS. Beyond checking the signature and expiry, which Cognito-specific claims must you check, and what does offline verification fail to notice?

level: seniorimportance: should knowfreq 48%

answer

  1. signature proves authenticity, not intent
  2. pin the issuer to your pool
  3. access tokens do not carry aud
  4. a snapshot frozen at mint time
  5. sign-out never reaches an offline verifier

basics

~20 s

Check that iss names your pool, that token_use matches the token type you expect, and that the audience claim — aud on an ID token, client_id on an access token — is your app client. Offline verification cannot see revocation, disabled users or changed groups until the token expires.

solid answer

~50 s

Signature and expiry only prove the token is authentic and current; they do not prove it was minted for you. Three Cognito claims close that gap. `iss` must equal `https://cognito-idp.{region}.amazonaws.com/{userPoolId}` — this pins the token to your pool rather than any pool in the region. `token_use` must be the type you intend to accept, `"access"` for a resource server, because the two token types differ in meaning and an attacker will happily hand you the wrong one. The audience must be an app client you trust: on an ID token that is `aud`, on an access token it is `client_id`, which trips people up. Cache the JWKS rather than fetching per request, keyed by `kid`. What offline verification structurally cannot see is anything that changed after minting: a revoked refresh token, a disabled or deleted user, a group removal, a password reset. Until that access token expires, your service still honours it — which is the real argument for short token lifetimes.

code

javascript · 18 lines
javascript
const { CognitoJwtVerifier } = require("aws-jwt-verify");

const verifier = CognitoJwtVerifier.create({
  userPoolId: "us-east-1_EXAMPLE",
  tokenUse: "access",
  clientId: "7example12345clientid",
});

async function authorize(bearerToken) {
  try {
    const payload = await verifier.verify(bearerToken);
    return { userId: payload.sub, groups: payload["cognito:groups"] ?? [] };
  } catch {
    return null;
  }
}

module.exports = { authorize };

go deeper

for a junior

Know that a Cognito token must be verified against the pool's published keys, and that your code should never trust claims from a token it merely decoded.

for a middle

Name the three claim checks — iss, token_use and the audience claim, which is client_id on access tokens — and explain why each one closes a specific hole.

for a senior

Reason about the staleness window: what an offline verifier cannot observe, how token lifetime bounds the exposure, and where a live revocation check is worth its latency.

for a principal

Decide the organization's stance on stateless verification versus a revocation path, and set the token-lifetime policy that makes that stance defensible across every service consuming these tokens.

## What the signature actually buys you A Cognito user pool signs its ID and access tokens with RS256 and publishes the corresponding public keys as a JWK Set at `https://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/jwks.json`. Verifying against that set proves two things: the token was issued by that pool, and it has not been altered. It proves nothing about whether the token was meant for your service, whether it is the right kind of token, or whether the facts it asserts are still true. ## The Cognito-specific claim checks **Issuer.** `iss` must exactly equal `https://cognito-idp.{region}.amazonaws.com/{userPoolId}`. Anyone can create a user pool in the same region; without an issuer check you would accept tokens from a pool the attacker owns, correctly signed by keys you correctly fetched from *that* pool. Pin the full string, not a prefix. **Token type.** `token_use` is `"id"` or `"access"`. A resource server should accept only `"access"`. This is not pedantry: the two tokens carry different claims and different guarantees, and an ID token presented to an API is a token being consumed outside its intended audience. Rejecting on `token_use` is a single comparison that removes a whole class of confusion. **Audience.** Here Cognito diverges from the shape people expect. The ID token has `aud` set to the app client ID. The access token has no `aud` — it carries `client_id` instead. A verifier written to check `aud` unconditionally will either reject every access token or, worse, skip the check when the claim is absent. Check `client_id` for access tokens and `aud` for ID tokens, against an allow-list of the app clients you trust; a pool can have many app clients, and not all of them should reach every service. **Key selection.** The token header's `kid` selects which JWK verifies it. A pool publishes a small, stable set of signing keys, so fetch the JWKS once, cache it, and refetch only when an unknown `kid` appears — with a rate limit, so an attacker cannot turn unknown `kid` values into a request amplifier against Cognito. AWS publishes a library that encodes all of this, which is worth naming in an interview rather than hand-rolling: ```javascript const { CognitoJwtVerifier } = require("aws-jwt-verify"); const verifier = CognitoJwtVerifier.create({ userPoolId: "us-east-1_EXAMPLE", tokenUse: "access", clientId: "7example12345clientid", }); ``` ## What offline verification cannot see This is the half of the answer that separates a senior response. A locally verified JWT is a snapshot of facts frozen at mint time. Between minting and expiry, your service will keep honouring it through all of the following: - **Revocation.** Cognito's `RevokeToken` API invalidates a refresh token and the tokens derived from it, and a global sign-out has the same intent. Cognito enforces that at its own endpoints. A resource server verifying signatures locally never asks Cognito anything, so it cannot observe the revocation. - **User lifecycle.** Disabling or deleting a user stops future sign-ins. It does not reach into tokens already in flight. - **Authorization drift.** Removing someone from a `cognito:groups` group, or changing an attribute your policy reads, only takes effect on the next token. - **Password change.** Rotating a compromised password does not retract the access tokens the attacker already holds. There are three honest responses, and the interviewer usually wants to hear you weigh them. First, **shorten the access token lifetime** — the window is the exposure, and Cognito lets you configure it per app client. Second, **check a revocation signal for high-value operations only** — a denylist in a fast store, or a live call, on the small set of endpoints that justify the latency. Third, **accept the window deliberately** and document it, which is legitimate for most read paths. What is not acceptable is claiming that sign-out immediately kills API access when your API verifies tokens offline; that is the most common wrong answer here. ## Adjacent traps - Do not trust the `alg` header to select the algorithm; fix it to RS256 in the verifier configuration. - Do not fetch the JWKS on every request; it adds latency and a hard dependency on Cognito's availability to your request path. - Do not verify the token and then read claims from a separately decoded copy — verify once and use the verified payload. - Federated users arrive with claims mapped from the external provider; treat `email` as attacker-influenced unless the pool is configured such that it is genuinely verified.

  • A user reports they signed out on one device but the mobile app kept working for another twenty minutes. Explain it.
    Sign-out revokes the refresh token at Cognito, so no new tokens can be minted, but the access token already on the device stays cryptographically valid until its expiry. Your API verifies it offline and has no way to learn about the revocation. The twenty minutes is the remaining token lifetime. Shorten it, or add a revocation check on sensitive endpoints.
  • Why is checking client_id rather than aud a real trap on Cognito access tokens?
    Because most JWT libraries expect the audience in aud, and Cognito puts the app client ID in client_id on access tokens while reserving aud for ID tokens. A verifier configured to require aud rejects every access token; a verifier that treats a missing aud as "no audience restriction" silently accepts tokens from app clients it never meant to trust.
  • How often should your service fetch the pool's JWKS?
    Once at startup, then cache. Refetch only when a token presents an unknown kid, and rate-limit that path so unknown key ids cannot be used to hammer Cognito. Fetching per request adds latency to every call and couples your availability to Cognito's endpoint for no benefit, since pool signing keys are stable.

saying these in an interview costs you the question

  • Verifies the signature and stops there
  • Assumes a signed token was necessarily minted for this service
  • Believes sign-out immediately blocks API calls
  • Skips the audience check when aud is absent
  • Fetches the JWKS on every incoming request

context