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