Amazon Cognito for Python Backends
Practical use of Cognito from a Python backend (FastAPI / Django). The big AWS-flavored auth story: User Pools for user identity, Identity Pools for handing out AWS credentials. Most backend apps only need User Pools.
For the AWS-service overview (user pools vs identity pools, hosted UI, federation, pricing), see Amazon Cognito.
User Pool vs Identity Pool
| User Pool | Identity Pool | |
|---|---|---|
| What | user directory + auth | swap a user’s identity → AWS credentials |
| Returns | JWT (ID, access, refresh tokens) | temporary IAM creds (STS) |
| Use case | “sign in to my app” | “let this user upload directly to S3” |
| Backend need | validate JWT on requests | usually skipped (use IAM roles instead) |
If you’re building a backend API: User Pool is enough for almost everything. Identity Pool is for direct-to-AWS-from-browser patterns (rare in proper backend architectures).
The tokens
A User Pool sign-in returns three tokens:
| Token | Use |
|---|---|
| ID token (JWT) | who the user is — claims like email, cognito:groups. For the client app, not for APIs. |
| Access token (JWT) | what they can do — scope claim. Backend validates this for protected APIs. |
| Refresh token | get new ID + access tokens without re-login. Long-lived (days). |
Default lifetimes: ID and access tokens 60 minutes (configurable 5 minutes to 24 hours); refresh token 30 days (60 minutes to 10 years), per app client. The general trade-offs are in Access and Refresh Tokens.
Validating Cognito JWT in FastAPI
import jwt
import requests
from fastapi import Depends, HTTPException, Header
from functools import lru_cache
REGION = "us-east-1"
USER_POOL_ID = "us-east-1_abcDEF123"
APP_CLIENT_ID = "1a2b3c4d5e6f7g8h9i0j"
ISSUER = f"https://cognito-idp.{REGION}.amazonaws.com/{USER_POOL_ID}"
JWKS_URL = f"{ISSUER}/.well-known/jwks.json"
@lru_cache(maxsize=1)
def _jwks():
return jwt.PyJWKClient(JWKS_URL)
def verify_access_token(token: str) -> dict:
try:
signing_key = _jwks().get_signing_key_from_jwt(token).key
claims = jwt.decode(
token,
signing_key,
algorithms=["RS256"],
issuer=ISSUER, # no audience=: access tokens have no aud
options={"require": ["exp", "iat", "iss", "sub", "token_use"]},
)
except jwt.PyJWTError:
raise HTTPException(401, "Invalid token")
if claims["token_use"] != "access": # an ID token is not an API credential
raise HTTPException(401, "Access token required")
if claims.get("client_id") != APP_CLIENT_ID:
raise HTTPException(401, "Unknown client")
return claims
async def current_user(authorization: str = Header(...)) -> dict:
if not authorization.startswith("Bearer "):
raise HTTPException(401, "Missing bearer token")
return verify_access_token(authorization[len("Bearer "):])
@app.get("/me")
async def me(user = Depends(current_user)):
return {"sub": user["sub"], "groups": user.get("cognito:groups", [])}Critical validations (Cognito-specific):
issmatches your pool’s URL exactly.token_useis"access"for an API. Accepting"id"lets the client app’s identity token act as an API credential.client_idis one of your app clients. Access tokens have noaud: passingaudience=to PyJWT rejects every one of them (InvalidAudienceError).audexists only on ID tokens.- Signature verifies against the JWKS key matching the
kidheader. expnot in the past.
The access token carries sub, cognito:groups, scope and client_id, but not email; fetch profile data from your own user table or the ID token on the client.
ID vs access token — which to send?
- ID token — about the user. Use to know who they are. Don’t send to APIs as bearer per OIDC spec; it’s a UX/identity token.
- Access token — about authorization. Send as
Bearerto APIs.
Many shops send ID tokens because the email is right there. It works until it doesn’t: an ID token has no scopes, is minted for the client app (aud), and accepting it means any app that can obtain one for your pool can call your API. Send access tokens; add claims with the pre-token-generation trigger (V2 event) if the API needs them.
API Gateway native integration
If you front your API with API Gateway, use the Cognito User Pool authorizer:
# CloudFormation snippet
ApiAuthorizer:
Type: AWS::ApiGateway::Authorizer
Properties:
Name: CognitoAuthorizer
Type: COGNITO_USER_POOLS
IdentitySource: method.request.header.Authorization
RestApiId: !Ref Api
ProviderARNs: [!Sub "arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/${UserPoolId}"]API Gateway validates the JWT before invoking your Lambda/backend. Your backend trusts the request, reads claims from event.requestContext.authorizer.claims. Skips the JWKS dance.
For HTTP APIs (cheaper than REST APIs), use a JWT authorizer:
Authorizers:
cognito:
JwtConfiguration:
Issuer: https://cognito-idp.us-east-1.amazonaws.com/us-east-1_xxx
Audience: [your-app-client-id]
IdentitySource: $request.header.AuthorizationGroups vs custom claims
- Cognito Groups — built-in user→groups mapping. Appears in tokens as
"cognito:groups": ["admin", "billing"]. Easy to manage in console / API. - Custom attributes —
custom:tenant_id,custom:role. Set per-user, appears in tokens.
For RBAC:
def require_group(group: str):
async def checker(user = Depends(current_user)):
if group not in user.get("cognito:groups", []):
raise HTTPException(403, "Forbidden")
return user
return checker
@app.get("/admin/reports")
async def reports(user = Depends(require_group("admin"))):
...Custom attributes need to be marked as readable by the App Client and included in the token. There’s a per-token claim size limit; don’t dump huge lists.
Lambda triggers — extending the auth flow
Cognito invokes Lambdas at specific lifecycle points:
| Trigger | When | Use case |
|---|---|---|
PreSignUp |
before user is created | auto-confirm, custom validation |
PostConfirmation |
after email/phone verified | sync user to your DB |
PreAuthentication |
before sign-in attempt | block based on custom logic |
PostAuthentication |
after sign-in success | update last-login, audit |
PreTokenGeneration |
before token issued | add custom claims |
DefineAuthChallenge / CreateAuthChallenge / VerifyAuthChallengeResponse |
custom auth flow | passwordless, magic links, MFA logic |
UserMigration |
first sign-in of unmigrated user | migrate from old system on-the-fly |
PreTokenGeneration
PreTokenGeneration is the most useful — inject claims at issuance. The event
version decides which token you can change: V1_0 changes only the ID
token (and groups); V2_0 (Essentials/Plus plan) also the access token; V3_0
also machine-to-machine access tokens. An API reads the access token, so this
needs V2:
def lambda_handler(event, context): # trigger configured as V2_0
tenant_id = lookup_tenant(event["request"]["userAttributes"]["sub"])
event["response"]["claimsAndScopeOverrideDetails"] = {
"accessTokenGeneration": {
"claimsToAddOrOverride": {"tenant_id": tenant_id}
}
}
return eventWith the V1 shape (claimsOverrideDetails), the claim lands only in the ID
token, and the API never sees it.
UserMigration
UserMigration is the trick for migrating users from a legacy auth system without forcing password resets:
def lambda_handler(event, context):
if event["triggerSource"] == "UserMigration_Authentication":
# User trying to sign in for the first time on Cognito
if verify_with_old_system(event["userName"], event["request"]["password"]):
event["response"]["userAttributes"] = {"email": event["userName"], "email_verified": "true"}
event["response"]["finalUserStatus"] = "CONFIRMED"
event["response"]["messageAction"] = "SUPPRESS" # don't send welcome email
return event
raise Exception("Bad credentials")Refresh tokens and rotation
Refresh tokens are long-lived (default 30 days). Without rotation, the
REFRESH_TOKEN_AUTH flow returns new ID and access tokens and the refresh
token stays the same:
import boto3
cognito = boto3.client("cognito-idp")
resp = cognito.initiate_auth(
AuthFlow="REFRESH_TOKEN_AUTH",
AuthParameters={"REFRESH_TOKEN": refresh_token},
ClientId=APP_CLIENT_ID,
)
new_access = resp["AuthenticationResult"]["AccessToken"]Since 2025, app clients can enable refresh token rotation
(RefreshTokenRotation, with a 0-60 s retry grace period). Then every refresh
returns a new refresh token, REFRESH_TOKEN_AUTH stops working for that
client, and refreshes go through get_tokens_from_refresh_token (or the
/oauth2/token endpoint). The rotated token keeps the original expiry. Design
background: Refresh Token Rotation.
Revocation — RevokeToken invalidates a refresh token, GlobalSignOut all of a user’s. Your backend’s offline JWT check never sees either: an access token stays valid until exp, so the access-token lifetime is your revocation window.
For long-session apps, set short access token (15 min) + medium refresh (24h). Reduces blast radius of token theft.
Hosted UI vs custom UI
- Managed login (formerly the Hosted UI, now “classic hosted UI”) — Cognito’s sign-in pages, with a branding editor and passkeys on the Essentials and Plus plans.
- Custom UI — your own UI using Cognito SDK or the
initiate_authAPI. Production default.
Managed login is the simplest way to use external identity providers (Google, Facebook, SAML) without writing OAuth code yourself.
Common gotchas
- Wrong issuer in JWT validation.
issclaim ishttps://cognito-idp.{REGION}.amazonaws.com/{USER_POOL_ID}— no trailing slash, region-specific. Mismatch = 401. - Accepting ID tokens as API bearers. Check
token_use == "access". - JWKS rotation. Cognito rotates signing keys occasionally; cache JWKS but refresh on
kidmiss.PyJWKClientdoes this. - Email as username. If you allow email sign-in, watch the
preferred_usernamevsemailclaim distinction — users can change email, breaking your identity assumption. Usesub(immutable UUID) as your DB foreign key. - App Client secret with browser SDK. Don’t enable a client secret on App Clients used by browsers — secrets in JS bundles are not secrets.
InvalidParameterException: Cannot reset password for the user as there is no registered/verified email or phone_number— user wasn’t confirmed. Send confirmation code first.
Cost
Since 2024-11-22 pools are on one of three plans: Lite (from $0.0055/MAU), Essentials (the default, $0.015/MAU) and Plus ($0.02/MAU). Lite and Essentials include 10,000 free MAU; federated users have 50 free MAU. The old 50,000-MAU free tier survives only for Lite pools in accounts active before that date. Details and machine-to-machine pricing in the Cognito service note.
When NOT to use Cognito
- Need fine-grained RBAC beyond simple groups → use a real authz layer (OPA, custom).
- Multi-tenant apps with isolated user dirs per tenant → separate User Pools or use external providers.
- Heavy customization of token claims dynamically → Cognito’s PreTokenGeneration has limits; consider Auth0 or your own.
- You want a single solution across non-AWS clouds → use Auth0, Keycloak, or roll your own.
Interview angle 7
- “How do you validate a Cognito token in FastAPI?” — for an API, the access token: verify the signature against the JWKS key for the token’s
kid, then checkiss,exp,token_use == "access"andclient_idagainst your app clients. Notaud: access tokens have none, and requiring it rejects them all. - “User Pool vs Identity Pool?” — User Pool is the user directory + sign-in (returns JWTs). Identity Pool exchanges a JWT for temporary AWS IAM creds (lets the client call AWS directly). Most backend apps need only User Pool.
- “ID token vs access token — which to send to your API?” — the access token. The ID token is minted for the client app, carries no scopes, and accepting it lets any app that can get one for your pool call your API.
- “How do you add custom claims to a Cognito JWT?” —
PreTokenGenerationLambda trigger; with a V2 event (claimsAndScopeOverrideDetails.accessTokenGeneration) to reach the access token, since V1 changes only the ID token. - “How do you migrate users from a legacy system to Cognito without forcing password resets?” —
UserMigrationLambda trigger. On the user’s first Cognito sign-in, verify against the old system, then create the Cognito user withmessageAction: SUPPRESSso no email goes out. Transparent to users. - “How does API Gateway integrate with Cognito?” — for REST APIs:
COGNITO_USER_POOLSauthorizer (validates JWT, attaches claims toevent.requestContext.authorizer.claims). For HTTP APIs: JWT authorizer withIssuerandAudienceconfig. Either way, the Lambda/backend reads pre-validated claims. - “What if your Cognito JWT validation succeeds but the user shouldn’t have access?” — that’s an authorization (authz) problem, separate from authentication. Cognito Groups + your own RBAC layer. JWT validity ≠ permission to do anything specific.