AWS Cognito: User Authentication and Authorization That Scales to Millions

Building authentication from scratch is a mistake most teams make once. Password hashing, secure reset flows, MFA, social login, token refresh, session invalidation on logout, account lockout after failed attempts. Each of these is a solved problem individually. Together they add up to months of work that is entirely orthogonal to your actual product.

AWS Cognito gives you a managed identity service that covers user pools for authentication, identity pools for AWS credential vending, and federation with external identity providers. In this article I will walk through a production Cognito User Pool setup with MFA, Lambda triggers for custom logic, and the token patterns your backend APIs need to validate requests securely.

User Pool Setup

resource "aws_cognito_user_pool" "main" {
  name = "production-user-pool"

  username_attributes      = ["email"]
  auto_verified_attributes = ["email"]

  username_configuration {
    case_sensitive = false
  }

  password_policy {
    minimum_length                   = 12
    require_uppercase                = true
    require_lowercase                = true
    require_numbers                  = true
    require_symbols                  = true
    temporary_password_validity_days = 3
  }

  mfa_configuration = "OPTIONAL"

  software_token_mfa_configuration {
    enabled = true
  }

  account_recovery_setting {
    recovery_mechanism {
      name     = "verified_email"
      priority = 1
    }
  }

  email_configuration {
    email_sending_account = "DEVELOPER"
    from_email_address    = "noreply@yourdomain.com"
    source_arn            = aws_ses_email_identity.main.arn
  }

  verification_message_template {
    default_email_option = "CONFIRM_WITH_CODE"
    email_subject        = "Your verification code"
    email_message        = "Your verification code is {####}"
  }

  user_pool_add_ons {
    advanced_security_mode = "ENFORCED"
  }

  schema {
    name                     = "organization_id"
    attribute_data_type      = "String"
    mutable                  = false
    required                 = false
    string_attribute_constraints {
      min_length = 1
      max_length = 36
    }
  }

  schema {
    name                = "role"
    attribute_data_type = "String"
    mutable             = true
    required            = false
    string_attribute_constraints {
      min_length = 1
      max_length = 50
    }
  }

  lambda_config {
    pre_sign_up         = aws_lambda_function.pre_signup.arn
    post_confirmation   = aws_lambda_function.post_confirmation.arn
    pre_token_generation = aws_lambda_function.pre_token.arn
  }

  tags = {
    Environment = "production"
    ManagedBy   = "terraform"
  }
}

resource "aws_cognito_user_pool_domain" "main" {
  domain       = "auth.yourdomain.com"
  certificate_arn = aws_acm_certificate.cognito.arn
  user_pool_id = aws_cognito_user_pool.main.id
}

advanced_security_mode = ENFORCED enables Cognito Advanced Security, which adds adaptive authentication. It analyzes sign-in risk signals like unusual device, IP reputation, and impossible travel, and can automatically block or challenge suspicious sign-ins. ENFORCED means high-risk sign-ins are blocked. AUDIT mode logs the risk assessment without blocking, which is useful during initial rollout to understand the false positive rate before switching to enforcement.

DEVELOPER email sending mode routes emails through SES rather than Cognito’s default email service. The default service has a limit of 50 emails per day per account, which you will hit immediately in production. Always configure SES from the start.

App Clients

resource "aws_cognito_user_pool_client" "web_app" {
  name         = "web-app"
  user_pool_id = aws_cognito_user_pool.main.id

  generate_secret                      = false
  prevent_user_existence_errors        = "ENABLED"
  enable_token_revocation              = true
  enable_propagate_additional_user_context_data = true

  allowed_oauth_flows_user_pool_client = true
  allowed_oauth_flows                  = ["code"]
  allowed_oauth_scopes                 = ["openid", "email", "profile"]
  callback_urls                        = ["https://app.yourdomain.com/callback"]
  logout_urls                          = ["https://app.yourdomain.com/logout"]

  supported_identity_providers = ["COGNITO", "Google"]

  token_validity_units {
    access_token  = "hours"
    id_token      = "hours"
    refresh_token = "days"
  }

  access_token_validity  = 1
  id_token_validity      = 1
  refresh_token_validity = 30

  read_attributes  = ["email", "email_verified", "custom:organization_id", "custom:role"]
  write_attributes = ["email", "custom:role"]
}

resource "aws_cognito_user_pool_client" "backend_service" {
  name         = "backend-service"
  user_pool_id = aws_cognito_user_pool.main.id

  generate_secret              = true
  prevent_user_existence_errors = "ENABLED"
  enable_token_revocation      = true

  allowed_oauth_flows_user_pool_client = true
  allowed_oauth_flows                  = ["client_credentials"]
  allowed_oauth_scopes                 = ["orders/read", "orders/write"]

  access_token_validity = 1
  token_validity_units {
    access_token = "hours"
  }
}

prevent_user_existence_errors = ENABLED returns a generic error for both invalid username and invalid password. Without this, an attacker can enumerate valid usernames by observing whether the error says the user does not exist or the password is wrong. Always enable this on production clients.

The backend service client uses client_credentials flow for machine-to-machine authentication. This produces an access token without a user context, scoped to the OAuth scopes you define. Your order fulfillment service authenticates with its client ID and secret and gets a token scoped to orders/read and orders/write, which AppSync or API Gateway can validate.

Pre-Token Generation Lambda

The pre_token_generation trigger runs before Cognito issues tokens. Use it to add custom claims from your database that your API needs for authorization decisions.

def lambda_handler(event, context):
    user_sub        = event["request"]["userAttributes"]["sub"]
    organization_id = event["request"]["userAttributes"].get("custom:organization_id")
    role            = event["request"]["userAttributes"].get("custom:role", "member")

    permissions = get_permissions_for_role(role, organization_id)

    event["response"]["claimsOverrideDetails"] = {
        "claimsToAddOrOverride": {
            "organization_id": organization_id or "",
            "role":            role,
            "permissions":     ",".join(permissions)
        }
    }

    return event

def get_permissions_for_role(role: str, org_id: str) -> list:
    permissions_map = {
        "admin":  ["orders:read", "orders:write", "users:read", "users:write"],
        "member": ["orders:read", "orders:write"],
        "viewer": ["orders:read"]
    }
    return permissions_map.get(role, [])

Custom claims in the token mean your API can make authorization decisions without a database lookup on every request. The token itself carries the user’s organization, role, and permissions. Validate the token signature and read the claims. No round trip to DynamoDB or RDS required.

Token Validation in Your API

import boto3
import json
import urllib.request
from jose import jwk, jwt
from jose.utils import base64url_decode

REGION      = "us-east-1"
USER_POOL_ID = "us-east-1_xxxxxxxxx"
JWKS_URL    = f"https://cognito-idp.{REGION}.amazonaws.com/{USER_POOL_ID}/.well-known/jwks.json"

_jwks_cache = None

def get_jwks():
    global _jwks_cache
    if _jwks_cache is None:
        with urllib.request.urlopen(JWKS_URL) as response:
            _jwks_cache = json.loads(response.read())
    return _jwks_cache

def validate_token(token: str) -> dict:
    headers = jwt.get_unverified_headers(token)
    kid     = headers["kid"]

    jwks    = get_jwks()
    key     = next((k for k in jwks["keys"] if k["kid"] == kid), None)

    if not key:
        raise ValueError("Public key not found")

    public_key = jwk.construct(key)

    claims = jwt.decode(
        token,
        public_key,
        algorithms=["RS256"],
        audience=CLIENT_ID,
        issuer=f"https://cognito-idp.{REGION}.amazonaws.com/{USER_POOL_ID}"
    )

    return claims

Caching the JWKS response is important. The public keys rarely change and fetching them on every request adds latency and creates an unnecessary dependency on Cognito’s JWKS endpoint. Cache them in memory for the Lambda container lifetime. For long-running services, refresh the cache on a timer or on key-not-found errors.

Closing Thoughts

Cognito handles the authentication surface that would take months to build securely from scratch. The investment is in understanding the model: user pools for human authentication, app clients for each application tier, Lambda triggers for custom logic, and token claims for API authorization.

Enable advanced security from day one. Configure SES before you go live. Use the pre-token generation trigger to embed the claims your APIs need so they can authorize without database lookups. The result is a secure, scalable identity layer that your team does not need to maintain.

Enjoy the cloud.

Osama


#AWS #Cognito #Authentication #Authorization #CloudSecurity #IdentityManagement #Terraform #InfrastructureAsCode #AmazonWebServices #SolutionsArchitect #CloudNative #CloudComputing #BackendEngineering #OAuth #JWT #TechBlog #CloudInfrastructure #MFA #UserManagement #DevOps

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.