Backend / Authentication / 03_cognito_python.md

Amazon Cognito for Python Backends

Updated 7 interview angles 8 min read source
On this page13
  1. User Pool vs Identity Pool
  2. The tokens
  3. Validating Cognito JWT in FastAPI
  4. ID vs access token — which to send?
  5. API Gateway native integration
  6. Groups vs custom claims
  7. Lambda triggers — extending the auth flow
  8. Refresh tokens and rotation
  9. Hosted UI vs custom UI
  10. Common gotchas
  11. Cost
  12. When NOT to use Cognito
  13. Interview angle

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

python
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):

  • iss matches your pool’s URL exactly.
  • token_use is "access" for an API. Accepting "id" lets the client app’s identity token act as an API credential.
  • client_id is one of your app clients. Access tokens have no aud: passing audience= to PyJWT rejects every one of them (InvalidAudienceError). aud exists only on ID tokens.
  • Signature verifies against the JWKS key matching the kid header.
  • exp not 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 Bearer to 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:

yaml
# 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:

yaml
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:

python
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:

python
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 event

With 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:

python
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:

python
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_auth API. 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. iss claim is https://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 kid miss. PyJWKClient does this.
  • Email as username. If you allow email sign-in, watch the preferred_username vs email claim distinction — users can change email, breaking your identity assumption. Use sub (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 check iss, exp, token_use == "access" and client_id against your app clients. Not aud: 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?” — PreTokenGeneration Lambda 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?” — UserMigration Lambda trigger. On the user’s first Cognito sign-in, verify against the old system, then create the Cognito user with messageAction: SUPPRESS so no email goes out. Transparent to users.
  • “How does API Gateway integrate with Cognito?” — for REST APIs: COGNITO_USER_POOLS authorizer (validates JWT, attaches claims to event.requestContext.authorizer.claims). For HTTP APIs: JWT authorizer with Issuer and Audience config. 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.