backend / authentication / 03_cognito_python.md

Amazon Cognito for Python Backends

7 interview angles 7 min read source

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

  • iss matches your pool’s URL exactly.
  • token_use is "id" or "access" — never accept a refresh token as an auth bearer.
  • aud matches your App Client ID (for ID tokens; access tokens use client_id instead).
  • Signature verifies against the JWKS key matching the kid header.
  • exp not 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 Bearer to 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 attributescustom: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_auth API. 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. iss claim is https://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 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

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’s kid, then check iss, aud (App Client ID), exp, and token_use == "id". PyJWKClient + jwt.decode handles 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?”PreTokenGeneration Lambda trigger. Lookup data in your DB and set claimsOverrideDetails.claimsToAddOrOverride. Limited size (per-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.