By AndyPublished
Cognito JWT Validation: How to Verify Amazon Cognito User Pool Tokens
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:
| Claim | ID token | Access token |
|---|---|---|
| token_use | "id" | "access" |
| App client ID | aud | client_id |
| aud | The app client ID | Present only when a resource binding was requested; then it is the API URL |
| Username | cognito:username | username |
| cognito:groups | Yes (array of group names) | Yes (array of group names) |
| scope | No | Yes (space-separated OAuth scopes) |
| User attributes (email, name, custom:*) | Yes | No (use the userInfo endpoint) |
| cognito:roles / cognito:preferred_role | Yes, when groups have IAM roles | No |
| origin_jti, jti, event_id, auth_time | Yes | Yes |
| Signing key (kid) | ID-token key | A 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.
usernameis 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 whosekidmatches the token header, and verify with RS256 only. Cache keys bykid; if a token from your issuer has an unknownkid, refresh the cache once, because Cognito may have rotated keys. - ·2. Expiry.
expmust be in the future. AWS specifically says to check this locally rather than callingGetUseroruserInfoto see whether the token still works. - ·3. Audience.
aud(ID token) orclient_id(access token) must equal your app client ID. - ·4. Issuer.
issmust be your user pool, for examplehttps://cognito-idp.us-east-1.amazonaws.com/us-east-1_EXAMPLE. - ·5. token_use.
accessif your API accepts access tokens only,idif 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.
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
scopeoption requires specific OAuth scopes on access tokens. - ·Errors are typed:
JwtExpiredError,CognitoJwtInvalidTokenUseError,CognitoJwtInvalidClientIdError,CognitoJwtInvalidGroupError,KidNotFoundInJwksErrorand others, all exported fromaws-jwt-verify/error. - ·Non-Cognito issuers:
JwtVerifierdoes 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 claimsCognito 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-urito the user pool URL, do not setaudiences, and add a customOAuth2TokenValidator<Jwt>that requirestoken_use == "access"and the expectedclient_id. See JWT in Spring Boot. - ·ASP.NET Core: set
Authorityto the user pool URL andValidateAudience = false, then checkclient_idandtoken_useinOnTokenValidatedor 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, andaudorclient_idagainst the configured audiences. It usesclient_idonly whenaudis 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 hasaudequal to the client ID and passes too. AWS recommends requiring authorization scopes on routes; ID tokens have noscopeclaim, 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
| Symptom | Likely cause |
|---|---|
| ParameterValidationError about the issuer | Token from another user pool or Region, or the verifier was created with the wrong userPoolId |
| Invalid audience with an access token | Checking aud instead of client_id; access tokens carry the app client in client_id |
| CognitoJwtInvalidTokenUseError | An ID token sent to an API that expects access tokens (or the reverse) |
| KidNotFoundInJwksError | Token signed by a different pool, or a JWKS cache that predates a key rotation |
| Groups missing from the token | User added to the group after the token was issued; groups update on the next token refresh |
| Token still works after sign-out | Offline 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.