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:
jwt.decode(token, key, algorithms=["RS256"], audience=APP_CLIENT_ID)jwt.exceptions.MissingRequiredClaimError: Token is missing the "aud" claimThe 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:
- Read
kidfrom the header; find that key in the cached JWKS. Refetch only on an unknownkid(key rotation), never per request. - Verify the signature with RS256, rejecting any other
alg. issequals the pool’s URL: tokens from another pool are also validly signed by AWS.expis in the future (allow a few seconds of clock skew).token_useisaccessfor an API. This stops an ID token being used as an access token.client_id(access) oraud(ID) is one of your app clients.scopecontains what this endpoint needs, e.g.orders/write.
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
audiencelist is matched againstaudorclient_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.
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-* ──▶ taskThe 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.
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
- 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-identitystraight to it, and unsigned headers are believed. - Session length is the ALB’s, not Cognito’s.
SessionTimeoutdefaults 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. - 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
subororigin_jtivalues 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_AUTHinInitiateAuthstops working for that client; refresh throughGetTokensFromRefreshTokenor the/oauth2/tokenrefresh_tokengrant.- Rotation does not extend the session: each new refresh token expires at the original token’s expiry.
- Access and ID tokens gain
jtiandorigin_jticlaims, 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
exppays 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) andUserCreation. A login storm after an outage returnsTooManyRequestsException; 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:
{"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
admingroup keeps admin for up to an hour: groups are in the token, and the token lives untilexp. - “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.
Related
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 checkssignerequals 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
AWSELBAuthSessionCookieshard, then redirect to Cognito/logout; otherwise either the ALB or managed login signs the user back in.
- 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
- “Your FastAPI backend validates Cognito tokens. What does it check?” -
signature against the cached JWKS by
kid,iss,exp,token_use == access,client_idin the allowed set, and the scope for the route.- Follow-up: why not
aud? Access tokens have noaud; requiring it rejects every one.audexists 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.
- Follow-up: why not
- “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-poolsub.
- Follow-up: how do you keep each user in their own S3 prefix? One
authenticated role with
- “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.