Amazon Cognito

Updated 12 min read index source
On this page11
  1. The pieces
  2. Verifying a token: what the backend checks
  3. The ALB does the login: the full request path
  4. Revocation: what a sign-out actually stops
  5. Customising tokens: Lambda triggers
  6. Plans and pricing
  7. Identity pools: AWS credentials for end users
  8. Where it breaks in production
  9. The trade not taken
  10. Related
  11. Interview angle

Amazon Cognito

Article.

Cognito is AWS’s managed identity service. It has two halves that are often confused. A user pool is a user directory and an OIDC provider: it signs users in and issues JWTs. An identity pool exchanges a token (from a user pool, Google, SAML and so on) for temporary AWS credentials, so a client can call S3 or DynamoDB directly. Most web backends need only the user pool.

The senior question is “a request reaches your API. Who verified it, against what, and what happens after the user signs out?” The answer depends on whether your service, API Gateway, or a load balancer does the checking, and a JWT checked offline knows nothing about a sign-out.

This note is the service view: pools, tokens, verification rules, the ALB integration, tiers and limits. FastAPI validation code, groups and trigger code are in Amazon Cognito for Python Backends; OAuth and OIDC theory is in OAuth 2.0 and OpenID Connect. Facts below are checked against AWS documentation and the us-east-1 price page as of October 2026.

The pieces

Piece What it is
User pool directory + OIDC issuer; issues JWTs
App client one per application; its own flows, scopes, lifetimes
Domain hosts managed login and /oauth2/* endpoints
Resource server your API’s custom scopes (orders/read)
Groups cognito:groups claim; optional IAM role
Identity pool token → temporary AWS credentials (STS)

Managed login is the current name of the hosted sign-in pages (the older version is now called the “classic hosted UI”). Managed login’s branding editor and passkey sign-in need the Essentials or Plus plan.

Three tokens, three jobs

Token Job Key claims
ID tells your app who the user is aud, email, groups
Access authorises calls to an API client_id, scope
Refresh gets new ID and access tokens opaque to you

Defaults per app client: ID and access tokens last 60 minutes (configurable 5 minutes to 24 hours); the refresh token lasts 30 days (60 minutes to 10 years).

The detail that breaks real code: a Cognito access token has no aud claim. It carries client_id instead. A library call that requires an audience rejects every access token:

python
jwt.decode(token, key, algorithms=["RS256"], audience=APP_CLIENT_ID)
text
jwt.exceptions.MissingRequiredClaimError: Token is missing the "aud" claim

The ID token has aud and is meant for the client app that signed the user in. Sending it to your API as a bearer token works, so teams ship it, but it carries profile data and no scopes, so it cannot carry API permissions.

Verifying a token: what the backend checks

The user pool signs with RS256 and publishes its keys at https://cognito-idp.<region>.amazonaws.com/<pool-id>/.well-known/jwks.json. A backend verifying offline does this, in order:

  1. Read kid from the header; find that key in the cached JWKS. Refetch only on an unknown kid (key rotation), never per request.
  2. Verify the signature with RS256, rejecting any other alg.
  3. iss equals the pool’s URL: tokens from another pool are also validly signed by AWS.
  4. exp is in the future (allow a few seconds of clock skew).
  5. token_use is access for an API. This stops an ID token being used as an access token.
  6. client_id (access) or aud (ID) is one of your app clients.
  7. scope contains what this endpoint needs, e.g. orders/write.
python
claims = jwt.decode(
    token, signing_key, algorithms=["RS256"],
    issuer=ISSUER, options={"require": ["exp", "iss", "token_use"]},
)
if claims["token_use"] != "access":
    raise Unauthorized("access token required")
if claims["client_id"] not in ALLOWED_CLIENTS:
    raise Unauthorized("unknown client")
if "orders/write" not in claims.get("scope", "").split():
    raise Forbidden("missing scope")

AWS also publishes aws-jwt-verify for Node.js, which does all of the above (and ALB tokens) with JWKS caching. In Python, PyJWT’s PyJWKClient caches keys; the checks after the signature are yours. Full FastAPI dependency: Amazon Cognito for Python Backends.

Who else can do the check

  • API Gateway has a Cognito authorizer (REST APIs) or a JWT authorizer (HTTP APIs). On HTTP APIs, the JWT authorizer’s audience list is matched against aud or client_id, so access tokens work.
  • An ALB can run the whole login itself, below.
  • Amazon Verified Permissions evaluates Cedar policies over token claims for fine-grained authorisation; it does not replace verification.

The ALB does the login: the full request path

An Application Load Balancer listener rule with an authenticate-cognito action handles the entire OIDC code flow, so the application behind it (say a Streamlit app on ECS) contains no login code at all.

text
browser ──1──▶ ALB (HTTPS listener, authenticate-cognito)
   ▲            │ no session cookie
   │            └─2─▶ redirect to Cognito managed login
   │                     │ user signs in
   └──────3──────────────┘ code → /oauth2/idpresponse
                ALB swaps code for tokens (server side),
                sets AWSELBAuthSessionCookie-0..n
browser ──4──▶ ALB ── forwards + x-amzn-oidc-* ──▶ task

The ALB sends three headers to the target:

Header Signed?
x-amzn-oidc-data yes: ES256 JWT of the user claims
x-amzn-oidc-identity no: plain sub
x-amzn-oidc-accesstoken no: plain access token

Trust only x-amzn-oidc-data, and only after verifying it. It is not a Cognito token: the ALB signs it with ES256, and its public key is at https://public-keys.auth.elb.<region>.amazonaws.com/<kid>. Check that the signer field in the JWT header equals your ALB’s ARN; otherwise a token from someone else’s ALB in the same region verifies too.

python
import jwt, requests

def alb_user(headers: dict) -> dict:
    token = headers["x-amzn-oidc-data"]
    hdr = jwt.get_unverified_header(token)
    if hdr["signer"] != ALB_ARN:
        raise Unauthorized("wrong signer")
    url = f"https://public-keys.auth.elb.{REGION}.amazonaws.com/{hdr['kid']}"
    pem = requests.get(url, timeout=2).text      # cache by kid
    return jwt.decode(token, pem, algorithms=["ES256"])

The ALB token is base64url with padding at the end; some strict JWT libraries reject it, which is why AWS’s own samples and aws-jwt-verify handle it explicitly.

Three things that make it secure, or not

  1. The task must be reachable only from the ALB. Its security group allows inbound only from the ALB’s security group. If the task has a public IP or a permissive group, anyone can send a forged x-amzn-oidc-identity straight to it, and unsigned headers are believed.
  2. Session length is the ALB’s, not Cognito’s. SessionTimeout defaults to 7 days (604,800 s); set it explicitly. While the access token is valid the ALB does not call Cognito at all; after it expires, the ALB uses the refresh token to renew, and a failed refresh sends the user to login.
  3. Logout is two steps. The app expires the AWSELBAuthSessionCookie-* cookies (all shards; there can be up to four), then redirects to Cognito’s /logout?client_id=...&logout_uri=..., which clears the managed login cookie. Skip the first and the ALB keeps letting the user in; skip the second and the next login is silent. The logout landing page must not sit behind an authenticate rule.

Streamlit 1.42+ also has st.login() with any OIDC provider including Cognito. st.logout() clears only Streamlit’s cookie, not the Cognito session, so the same second step applies.

Revocation: what a sign-out actually stops

Action Effect
RevokeToken(refresh) that refresh token and tokens issued from it
GlobalSignOut all the user’s refresh tokens (user’s own call)
AdminUserGlobalSignOut the same, called with IAM credentials
/logout endpoint clears the managed login browser cookie

After revocation, Cognito’s own APIs (such as GetUser) reject the access token. Your backend does not: an offline JWT check sees a valid signature and a future exp, and accepts the token until it expires. So:

  • the access-token lifetime is the real revocation window; keep it short (5-15 minutes for sensitive APIs);
  • if you need instant cut-off (fired employee, stolen laptop), keep a deny-list of sub or origin_jti values checked per request, or ask Cognito on high-risk operations only;
  • managed login’s session cookie survives GlobalSignOut, so a signed-out user who returns to managed login is signed straight back in unless you also hit /logout.

Refresh token rotation

Opt-in per app client since 2025. With RefreshTokenRotation enabled, every refresh returns a new refresh token and invalidates the old one after a grace period of 0-60 seconds (for retries).

  • REFRESH_TOKEN_AUTH in InitiateAuth stops working for that client; refresh through GetTokensFromRefreshToken or the /oauth2/token refresh_token grant.
  • Rotation does not extend the session: each new refresh token expires at the original token’s expiry.
  • Access and ID tokens gain jti and origin_jti claims, which give a stable session id for a deny-list.

Customising tokens: Lambda triggers

Trigger Typical use
Pre sign-up auto-confirm, block domains
Post confirmation create the app’s own user row
Pre token generation add or remove claims and scopes
Custom auth (define/create/verify) passwordless, OTP flows
User migration lazy import from an old store at first login

Pre token generation has three event versions, and the one you configure decides what you can change:

  • V1_0: ID token claims, and groups (which also land in the access token).
  • V2_0: also access token claims and scopes, for user sign-ins.
  • V3_0: also access tokens from machine (client-credentials) flows.

V2 and V3 need the Essentials or Plus plan. A trigger that adds a tenant_id to “the token” with V1 adds it only to the ID token, and the API, which reads the access token, never sees it.

Triggers run synchronously inside sign-in with a 5-second limit; a slow database call in a trigger is a failed login, and Cognito retries it up to three times.

Plans and pricing

Since 2024-11-22 a user pool is on one of three feature plans, billed per monthly active user (MAU, a user with any sign-in or refresh that month):

Plan Price per MAU Free MAU
Lite $0.0055 (then $0.0046 above 100k) 10,000
Essentials (default) $0.015 10,000
Plus $0.020 none
  • Users federated through SAML or OIDC: 50 free MAU, then $0.015.
  • Machine-to-machine: $0.00225 per client-credentials token request, no free tier. A service that fetches a token per call instead of caching it until exp pays per request: 10 req/s is about $58,000 a month.
  • Plus adds threat protection (compromised-credential checks, adaptive authentication); Essentials adds managed login, passkeys and access-token customisation.
  • Pools in accounts active before 2024-11-22 keep the old 50,000 free MAU on Lite.

Limits that show up in production

  • API quotas are per category per account per region, for example UserAuthentication (sign-in calls) and UserCreation. A login storm after an outage returns TooManyRequestsException; clients need backoff, and higher quotas are requested in advance.
  • Some choices are fixed at pool creation: sign-in identifier (username vs email), required standard attributes, and whether email is case-sensitive. Changing them means a new pool and a migration.
  • Password hashes cannot be exported. Leaving Cognito means a reset for every user or a lazy migration through the new provider.
  • Region: a pool lives in one region. Multi-region replication arrived in June 2026 as an Essentials/Plus add-on: a read-only standby pool in a second region takes over sign-in. Before that, DR meant a second pool and a forced password reset.

Identity pools: AWS credentials for end users

An identity pool turns a user-pool or social token into short-lived AWS credentials through STS, scoped by an IAM role:

  • Authenticated and unauthenticated (guest) roles; guest access is off unless you enable it.
  • Role selection: the default role, a rule on a token claim, or the user-pool group’s role (cognito:preferred_role).
  • Per-user isolation with a policy variable, so one role serves every user but each can reach only their own prefix:
json
{"Effect": "Allow",
 "Action": ["s3:GetObject", "s3:PutObject"],
 "Resource": "arn:aws:s3:::uploads/${cognito-identity.amazonaws.com:sub}/*"}

The sub here is the identity id from the identity pool, not the user-pool sub; mixing the two is a common bug when the backend later looks for a user’s files.

Use the enhanced auth flow (GetCredentialsForIdentity), which applies a scope-down policy; the basic flow lets the client call AssumeRoleWithWebIdentity for any role the trust policy allows.

Where it breaks in production

  • Every API call returns 401 after a library upgrade: the code passes audience= and the client switched from ID to access tokens.
  • A user removed from the admin group keeps admin for up to an hour: groups are in the token, and the token lives until exp.
  • “Sign out” that does not sign out: the ALB cookie was cleared but managed login was not, so the user is signed straight back in.
  • Forged identity on the task IP: an ECS task with a public IP accepting x-amzn-oidc-identity, which nothing signs.
  • A surprise M2M bill: a worker requesting a new client-credentials token per job.
  • A login outage that is your Lambda: a pre-token trigger calling a slow database during a traffic spike.

The trade not taken

Auth0, Okta, Keycloak or Entra ID instead of Cognito. They have richer admin tooling, organisation/tenant models and enterprise SSO configuration, and Keycloak is portable and free to run. Cognito wins when you are already on AWS, want IAM integration (identity pools, ALB, API Gateway) and low cost per user. What usually drives teams away: decisions frozen at pool creation, no password-hash export, and customisation that ends in Lambda triggers. Choosing among them: Choosing an identity provider.

Interview angle 4

  • “Walk a request from a user to your Streamlit app on ECS behind Cognito. Where is the token validated?” - the ALB runs the OIDC code flow with Cognito, keeps the session in its own cookie, and forwards x-amzn-oidc-data, an ES256 JWT it signed; the app verifies that against the ELB public key and checks signer equals its ALB’s ARN.
    • Follow-up: what stops someone hitting the task IP with a forged header? The task’s security group admits only the ALB’s security group, and the app trusts only the signed header, never x-amzn-oidc-identity.
    • Follow-up: how does logout actually end the session? Expire every AWSELBAuthSessionCookie shard, then redirect to Cognito /logout; otherwise either the ALB or managed login signs the user back in.
  • “Your FastAPI backend validates Cognito tokens. What does it check?” - signature against the cached JWKS by kid, iss, exp, token_use == access, client_id in the allowed set, and the scope for the route.
    • Follow-up: why not aud? Access tokens have no aud; requiring it rejects every one. aud exists on ID tokens, which are not for APIs.
    • Follow-up: you called GlobalSignOut and the API still accepts the token. Why? Offline validation never asks Cognito; the token is valid until exp. Short access tokens, plus a deny-list where it matters.
  • “User pool or identity pool?” - the user pool authenticates and issues JWTs; the identity pool exchanges a token for temporary AWS credentials so the client talks to AWS directly.
    • Follow-up: how do you keep each user in their own S3 prefix? One authenticated role with ${cognito-identity.amazonaws.com:sub} in the resource ARN; that id is the identity id, not the user-pool sub.
  • “What does Cognito cost at 200,000 users?” - on Essentials, 190,000 billable MAU x $0.015, about $2,850 a month; on Lite about $1,000.
    • Follow-up: and your 50 internal services using client credentials? M2M is per token request, so cache each token until it expires; fetching one per call is what makes the bill.

Contents 0