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 backend/19_cloud_aws/Security_Identity_and_Compliance/01_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. Backend validates this. |
| 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 + access = 1 hour; refresh = 30 days. Configurable per app client.
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_token(token: str) -> dict:
try:
signing_key = _jwks().get_signing_key_from_jwt(token).key
claims = jwt.decode(
token,
signing_key,
algorithms=["RS256"],
audience=APP_CLIENT_ID, # only for ID tokens
issuer=ISSUER,
options={"require": ["exp", "iat", "iss", "sub"]},
)
except jwt.PyJWTError as e:
raise HTTPException(401, f"Invalid token: {e}")
if claims.get("token_use") not in ("id", "access"):
raise HTTPException(401, "Wrong token_use")
return claims
async def current_user(authorization: str = Header(...)) -> dict:
if not authorization.startswith("Bearer "):
raise HTTPException(401, "Missing bearer token")
return verify_token(authorization[len("Bearer "):])
@app.get("/me")
async def me(user = Depends(current_user)):
return {"sub": user["sub"], "email": user.get("email"), "groups": user.get("cognito:groups", [])}
Critical validations (Cognito-specific):
issmatches your pool’s URL exactly.token_useis"id"or"access"— never accept a refresh token as an auth bearer.audmatches your App Client ID (for ID tokens; access tokens useclient_idinstead).- Signature verifies against the JWKS key matching the
kidheader. expnot in the past.
Access tokens DON’T have aud; check client_id claim instead.
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.
In practice many shops use ID tokens because the email/groups are right there. Both work; access is the spec-correct one.
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.Authorization
Groups 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 is the most useful — inject claims at issuance:
def lambda_handler(event, context):
# Add tenant_id from your DB to every token
tenant_id = lookup_tenant(event["request"]["userAttributes"]["sub"])
event["response"]["claimsOverrideDetails"] = {
"claimsToAddOrOverride": {"custom:tenant_id": tenant_id}
}
return event
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). Standard flow:
# Client refreshes when access token expires
import boto3
cognito = boto3.client("cognito-idp")
resp = cognito.initiate_auth(
AuthFlow="REFRESH_TOKEN_AUTH",
AuthParameters={"REFRESH_TOKEN": refresh_token, "DEVICE_KEY": "..."},
ClientId=APP_CLIENT_ID,
)
new_id = resp["AuthenticationResult"]["IdToken"]
new_access = resp["AuthenticationResult"]["AccessToken"]
# refresh token stays the same
Revocation — Cognito supports RevokeToken to invalidate a refresh token. Use this on logout. For ID/access tokens (short-lived), accept the short window — they expire in 1 hour.
For long-session apps, set short access token (15 min) + medium refresh (24h). Reduces blast radius of token theft.
Hosted UI vs custom UI
- Hosted UI — Cognito’s pre-built sign-in pages. Quick start; limited styling. Good for demos / MVPs.
- Custom UI — your own UI using Cognito SDK or the
initiate_authAPI. Production default.
Hosted UI is the only 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 refresh tokens as bearer. Always check
token_use. - 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
Cognito is generous on the free tier (50,000 MAU free). After that ~$0.0055/MAU for User Pools; pricing increases above 1M MAU. Advanced security features (adaptive auth, compromised credentials) cost extra.
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
- “How do you validate a Cognito ID token in FastAPI?” — fetch JWKS from
{issuer}/.well-known/jwks.json, validate signature with the key matching the token’skid, then checkiss,aud(App Client ID),exp, andtoken_use == "id".PyJWKClient+jwt.decodehandles most of this. - “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?” — access token is the spec-correct bearer; ID token contains user identity claims. In practice many APIs accept both; access is “cleaner”.
- “How do you add custom claims to a Cognito JWT?” —
PreTokenGenerationLambda trigger. Lookup data in your DB and setclaimsOverrideDetails.claimsToAddOrOverride. Limited size (per-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.