By AndyPublished

Cognito JWT Validation: How to Verify Amazon Cognito User Pool Tokens

To validate a Cognito JWT, verify its RS256 signature with the key whose kidmatches in https://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/jwks.json. Then check that exp is in the future, that iss is your user pool, and that token_use is access orid as your API expects. Finally, check the app client: audin an ID token, but client_id in an access token. In Node.js, AWS recommends theaws-jwt-verify library, which does all of this in one call. In front of an HTTP API, API Gateway's JWT authorizer can do it without any code. This guide follows the AWS "Verifying JSON web tokens" documentation and aws-jwt-verify 5.2.1, and the Node and Python examples were run against locally signed test tokens.

Cognito JWT Token Example

Every user pool token is a signed JWT (JWS) with a two-field header. Cognito signs with RS256 using a 2048-bit RSA key, and it uses different keys for ID tokens and access tokens, so theirkid values differ even within one session.

// header
{ "kid": "1234example=", "alg": "RS256" }

// access token payload (from the AWS docs, trimmed)
{
  "sub": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
  "cognito:groups": ["testgroup"],
  "iss": "https://cognito-idp.us-west-2.amazonaws.com/us-west-2_example",
  "version": 2,
  "client_id": "xxxxxxxxxxxxexample",
  "origin_jti": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
  "token_use": "access",
  "scope": "phone openid profile resourceserver.1/appclient2 email",
  "auth_time": 1676313851,
  "exp": 1676317451,
  "iat": 1676313851,
  "jti": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
  "username": "my-test-user"
}
// ID token payload (trimmed)
{
  "sub": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
  "cognito:groups": ["test-group-a", "test-group-b"],
  "email_verified": true,
  "iss": "https://cognito-idp.us-west-2.amazonaws.com/us-west-2_example",
  "cognito:username": "my-test-user",
  "aud": "xxxxxxxxxxxxexample",
  "token_use": "id",
  "auth_time": 1676312777,
  "exp": 1676316377,
  "iat": 1676312777,
  "email": "my-test-user@example.com"
}

Cognito JWT Claims: ID Token vs Access Token

The most common Cognito validation bug is applying ID-token rules to access tokens. The two differ in several places:

ClaimID tokenAccess token
token_use"id""access"
App client IDaudclient_id
audThe app client IDPresent only when a resource binding was requested; then it is the API URL
Usernamecognito:usernameusername
cognito:groupsYes (array of group names)Yes (array of group names)
scopeNoYes (space-separated OAuth scopes)
User attributes (email, name, custom:*)YesNo (use the userInfo endpoint)
cognito:roles / cognito:preferred_roleYes, when groups have IAM rolesNo
origin_jti, jti, event_id, auth_timeYesYes
Signing key (kid)ID-token keyA different access-token key
  • ·sub is the stable user identifier. AWS warns that it does not follow a strict UUID format, so do not validate it as one. username is not guaranteed unique.
  • ·cognito:groups is the natural place for role-based checks. The claim name contains a colon, so read it as payload["cognito:groups"].
  • ·Custom attributes appear in ID tokens as custom:name, and Cognito always writes them as strings, whatever the attribute type.
  • ·Extra claims can be added with a pre token generation Lambda trigger. Adding claims and scopes to access tokens requires the Essentials or Plus feature plan.

Which token should your API accept? Normally the access token: it carries scopes and exists to authorise API calls. The ID token tells the client who signed in. JWTs in OAuth 2.0 and OIDC explains the split.

Cognito JWT Decode: Reading the Payload

Decoding is not validation. The header and payload are just base64url JSON, and anyone can decode, or forge, them. Decoding is still the first debugging step: it tells you which pool issued the token (iss), which type it is (token_use), and whether it has expired. Paste a token into the jwtdecode.app decoder to see the Cognito JWT payload with readable timestamps. It runs in the browser, so the token is not uploaded. For scripts, see decoding a JWT on the command line. Never make an authorisation decision from decoded claims until the signature and the checks below have passed.

Cognito JWT Validation Steps

AWS's documented procedure comes down to five checks, in this order:

  • ·1. Signature. Fetch the JWKS from https://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/jwks.json, pick the key whose kid matches the token header, and verify with RS256 only. Cache keys by kid; if a token from your issuer has an unknown kid, refresh the cache once, because Cognito may have rotated keys.
  • ·2. Expiry. exp must be in the future. AWS specifically says to check this locally rather than calling GetUser or userInfo to see whether the token still works.
  • ·3. Audience. aud (ID token) or client_id (access token) must equal your app client ID.
  • ·4. Issuer. iss must be your user pool, for example https://cognito-idp.us-east-1.amazonaws.com/us-east-1_EXAMPLE.
  • ·5. token_use. access if your API accepts access tokens only, id if it accepts ID tokens only.

Original and updated issuer formats

User pools now offer two issuer types. The original issuer ishttps://cognito-idp.{region}.amazonaws.com/{userPoolId}. The updated issuer, which AWS recommends and which supports multi-Region replication, ishttps://issuer-cognito-idp.{region}.amazonaws.com/{userPoolId}. If you hard-code the issuer string, match the one your pool actually uses. aws-jwt-verify 5.x accepts either form for the configured pool. AWS notes that the updated issuer does not yet work with Application Load Balancer authentication or with API Gateway REST API Cognito authorizers.

ℹ
A valid signature and an unexpired exp do not prove the token hasn't been revoked. When a user signs out or you call RevokeToken, Cognito invalidates tokens that share the origin_jti, but an offline verifier cannot see that. Keep access-token lifetimes short (they can be set from 5 minutes to 1 day per app client), or check with Cognito when a request is sensitive. See JWT logout and revocation.

Cognito JWT Verifier: aws-jwt-verify

aws-jwt-verify (published by AWS Labs, installed with npm install aws-jwt-verify) knows the Cognito rules: it derives the issuer and JWKS URL from the pool ID and checkstoken_use and the correct client claim for each token type.

import { CognitoJwtVerifier } from "aws-jwt-verify";
import { JwtExpiredError } from "aws-jwt-verify/error";

// Create once, outside the request handler (or Lambda handler), so the JWKS cache is reused.
const verifier = CognitoJwtVerifier.create({
  userPoolId: "eu-west-2_EXAMPLE",
  tokenUse: "access",          // or "id"
  clientId: "1example23456789", // checked against client_id (access) or aud (id)
  groups: "admin",             // optional: require membership of cognito:groups
});

await verifier.hydrate();      // optional: pre-load the JWKS at start-up

export async function authenticate(authorizationHeader) {
  const token = authorizationHeader?.replace(/^Bearer /, "");
  try {
    return await verifier.verify(token); // returns the verified payload
  } catch (err) {
    if (err instanceof JwtExpiredError) throw new Error("token expired");
    throw new Error("invalid token");
  }
}
  • ·Multiple pools or clients: pass an array of configurations to create(), or a list of client IDs.
  • ·Extra checks: customJwtCheck: ({ header, payload, jwk }) => { ... } runs after the standard checks. Throw to reject the token.
  • ·Scopes: the scope option requires specific OAuth scopes on access tokens.
  • ·Errors are typed: JwtExpiredError, CognitoJwtInvalidTokenUseError, CognitoJwtInvalidClientIdError, CognitoJwtInvalidGroupError, KidNotFoundInJwksError and others, all exported from aws-jwt-verify/error.
  • ·Non-Cognito issuers: JwtVerifier does the same with an explicit issuer, audience and JWKS URI.

You can set clientId: null to skip the client check, but the library's README advises against it and so does this guide: without it, tokens from any app client in the pool are accepted.

Validating Cognito tokens in Python

Any standards-compliant JWT library works. With PyJWT 2.x, check client_id andtoken_use yourself, because access tokens have no aud to hand to the library:

import jwt  # pip install "pyjwt[crypto]"

REGION = "eu-west-2"
USER_POOL_ID = "eu-west-2_EXAMPLE"
APP_CLIENT_ID = "1example23456789"
ISSUER = f"https://cognito-idp.{REGION}.amazonaws.com/{USER_POOL_ID}"
jwks_client = jwt.PyJWKClient(f"{ISSUER}/.well-known/jwks.json")  # caches keys

def verify_access_token(token: str) -> dict:
    signing_key = jwks_client.get_signing_key_from_jwt(token)
    claims = jwt.decode(
        token,
        signing_key.key,
        algorithms=["RS256"],
        issuer=ISSUER,
        options={"require": ["exp", "iat", "iss", "token_use"], "verify_aud": False},
        leeway=30,
    )
    if claims["token_use"] != "access":
        raise jwt.InvalidTokenError("not an access token")
    if claims.get("client_id") != APP_CLIENT_ID:
        raise jwt.InvalidTokenError("wrong client_id")
    return claims

Cognito tokens in Spring Boot and ASP.NET Core

Cognito publishes standard OpenID Connect metadata athttps://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/openid-configuration, so framework resource servers can use the user pool URL as their issuer or authority. There is one catch: Cognito access tokens normally have no aud claim, so a framework's standard audience check rejects them.

  • ·Spring Boot: set issuer-uri to the user pool URL, do not set audiences, and add a custom OAuth2TokenValidator<Jwt> that requires token_use == "access" and the expected client_id. See JWT in Spring Boot.
  • ·ASP.NET Core: set Authority to the user pool URL and ValidateAudience = false, then check client_id and token_use in OnTokenValidated or an authorisation policy. See JWT in ASP.NET Core.

Disabling the generic audience check is only safe because you replace it with the client_idcheck. Without that replacement, your API accepts tokens issued to every app client in the pool. If you configure a resource binding, access tokens carry an aud (your API's URL) and the standard audience setting works again.

Cognito JWT Authorizer in API Gateway

For an API Gateway HTTP API, a JWT authorizer validates Cognito tokens before your integration runs:

aws apigatewayv2 create-authorizer \
  --api-id abc123 \
  --name cognito-jwt \
  --authorizer-type JWT \
  --identity-source '$request.header.Authorization' \
  --jwt-configuration Audience=1example23456789,Issuer=https://cognito-idp.eu-west-2.amazonaws.com/eu-west-2_EXAMPLE
  • ·The authorizer fetches keys from the issuer's jwks_uri, supports RSA algorithms only, and may cache keys for up to two hours. Allow a grace period when keys rotate.
  • ·It checks iss, exp, nbf, iat, and aud or client_id against the configured audiences. It uses client_id only when aud is absent, so for a Cognito access token the "audience" you configure is the app client ID.
  • ·It does not check token_use. An ID token for the same app client has aud equal to the client ID and passes too. AWS recommends requiring authorization scopes on routes; ID tokens have no scope claim, so they are then rejected.
  • ·Validated claims reach a Lambda integration at event.requestContext.authorizer.jwt.claims.

REST APIs use a different mechanism, the Cognito user pool authorizer. For custom logic, use a Lambda authorizer running aws-jwt-verify, created outside the handler so its key cache survives between invocations.

Troubleshooting Cognito JWT Validation

SymptomLikely cause
ParameterValidationError about the issuerToken from another user pool or Region, or the verifier was created with the wrong userPoolId
Invalid audience with an access tokenChecking aud instead of client_id; access tokens carry the app client in client_id
CognitoJwtInvalidTokenUseErrorAn ID token sent to an API that expects access tokens (or the reverse)
KidNotFoundInJwksErrorToken signed by a different pool, or a JWKS cache that predates a key rotation
Groups missing from the tokenUser added to the group after the token was issued; groups update on the next token refresh
Token still works after sign-outOffline checks cannot see revocation; keep lifetimes short or check with Cognito

For generic failures such as clock skew or a malformed header, see common JWT errors and fixes.

Summary

Cognito JWT validation means checking an RS256 signature against the user pool's JWKS (/.well-known/jwks.json under the issuer), then exp,iss, token_use, and the app client: audfor ID tokens, client_id for access tokens. Use CognitoJwtVerifier.create({ userPoolId, tokenUse, clientId })from aws-jwt-verify in Node.js, any standard JWT library elsewhere, or an API Gateway HTTP API JWT authorizer with route scopes. Base group-based access on cognito:groups, and keep token lifetimes short, because offline checks cannot detect revocation.

Ready to decode a token?
Use the free JWT decoder — paste any token for instant results, entirely in your browser.
Open JWT Decoder