Building a Global Multi-Region SaaS Platform on AWS
In this comprehensive guide, we'll build a production-ready, globally distributed SaaS application optimized for global performance with multi-region active-active deployment using serverless architecture.
The Use Case: Global SaaS Application
We're building a global SaaS platform with a leaderboard system that needs to: - Serve users across North America, Europe, Asia-Pacific, Latin America, and Africa with <100ms latency - Handle 10,000+ concurrent users globally - Support real-time leaderboards updated across all regions - Support user profiles, activity tracking, and achievements - Provide 99.99% uptime with automatic failover - Scale elastically from 0 to millions of users - Comply with GDPR, SOC2, and regional data residency requirements
Example Applications: - Gaming platforms - global leaderboards, player rankings - Fitness apps - workout tracking, user challenges - EdTech platforms - student progress, course rankings - Sales platforms - rep leaderboards, team performance
This architecture is fully serverless and applicable to any global SaaS: CRM systems, project management tools, analytics platforms, or social apps.
What We're Building
A fully serverless, multi-region active-active architecture with: - Global traffic routing via Route53 and CloudFront - Multi-region API deployment with automatic failover - DynamoDB Global Tables for user profiles and real-time leaderboards - Cognito multi-region with passwordless authentication (magic links) - Custom user ID management (avoiding Cognito sub trap) - API Gateway REST APIs for low-latency access worldwide - Lambda functions for business logic and data processing - Real-time monitoring across all regions with CloudWatch - Infrastructure as Code with Terraform for consistent deployments
Tech Stack: - Frontend: React/Next.js (static export to S3) - CDN: CloudFront with geo-routing - API: API Gateway REST + Lambda - Database: DynamoDB Global Tables (eventual consistency across regions) - Auth: Cognito User Pools (multi-region with passwordless) - Monitoring: CloudWatch, X-Ray - IaC: Terraform
Cost: ~$555-2,500/month depending on regions (9-34 regions)
Multi-Region Architecture Overview
Note: This diagram shows a 3-region deployment for simplicity. The architecture scales to 9 strategic regions (recommended) or all 34 AWS commercial regions for maximum coverage. See "Region Selection Strategy" section below for details.
┌────────────────────────────────────┐
│ End Users Worldwide │
│ (NA, EU, APAC, LATAM, MEA) │
└─────────────┬──────────────────────┘
│
┌─────────────▼──────────────┐
│ Route53 (Geo-Routing) │
│ + Health Checks │
└─────────────┬──────────────┘
│
┌─────────────▼──────────────────────────────┐
│ CloudFront Distribution │
│ (Single, Global CDN) │
│ │
│ Origins: │
│ - S3 (static assets: HTML/CSS/JS) │
│ - API Gateway us-east-2 │
│ - API Gateway eu-west-1 │
│ - API Gateway ap-southeast-1 │
│ - (+ more regions as needed) │
│ │
│ Routing: Latency-based to nearest API │
└──────────────┬──────────────────────────────┘
│
┌────────────────────┼────────────────────┐
│ │ │
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ API Gateway │ │ API Gateway │ │ API Gateway │
│ us-east-2 │ │ eu-west-1 │ │ ap-southeast-1 │
│ (REST API) │ │ (REST API) │ │ (REST API) │
└────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘
│ │ │
┌────────┴─────────┐ ┌────────┴─────────┐ ┌────────┴─────────┐
│ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼
┌─────────┐ ┌─────────┐┌─────────┐ ┌─────────┐┌─────────┐ ┌─────────┐
│ Lambda │ │ Lambda ││ Lambda │ │ Lambda ││ Lambda │ │ Lambda │
│ (Users) │ │(Leader) ││ (Users) │ │(Leader) ││ (Users) │ │(Leader) │
└────┬────┘ └────┬────┘└────┬────┘ └────┬────┘└────┬────┘ └────┬────┘
│ │ │ │ │ │
└────────────────┴──────────┴────────────────┴──────────┴────────────────┘
│
┌──────────────┼──────────────┐
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ DynamoDB │ │ DynamoDB │ │ DynamoDB │
│Global Table │◄┤Global Table │►│Global Table │
│ (Replica) │ │ (Replica) │ │ (Replica) │
│ │ │ │ │ │
│ - users │ │ - users │ │ - users │
│ - leaderboard│ │ - leaderboard│ │ - leaderboard│
└──────────────┘ └──────────────┘ └──────────────┘
▲ ▲ ▲
│ Bi-directional Replication (< 1 sec) │
└──────────────────────────────┘
┌──────────────────────────────────────────────────┐
│ Cognito User Pools │
│ │
│ Americas: us-east-2 pool (passwordless) │
│ Europe: eu-west-1 pool (passwordless) │
│ Asia: ap-southeast-1 pool (passwordless) │
│ │
│ User sync via Post Confirmation Lambda hooks │
└──────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────┐
│ CloudWatch (Multi-Region Dashboard) │
│ + SNS Alerts + X-Ray Tracing │
└──────────────────────────────────────────────────┘
Key Architecture Decisions
Why Multi-Region Active-Active? - <100ms latency globally: Users always connect to the nearest region - 99.99% availability: If one region fails, Route53 automatically routes to healthy regions - Data locality: Comply with GDPR and data residency laws - No single point of failure: Every component has regional redundancy
Why DynamoDB Global Tables? - Automatic bi-directional replication across all regions - Local read/write in each region with <1 second cross-region sync - Strong consistency within a region, eventual consistency cross-region - Automatic conflict resolution using last-writer-wins
Region Selection Strategy
AWS offers 34 commercial regions globally (as of 2024). Choosing the right regions balances latency, cost, and reliability.
Option 1: Strategic 9-Region Deployment (Recommended for Most Use Cases)
Deploy to regions that maximize global coverage while minimizing costs:
Americas (3 regions): - us-east-2
(Ohio) - More reliable than us-east-1 - us-west-2 (Oregon)
- West coast coverage - sa-east-1 (São Paulo) - Latin
America
Europe (2 regions): - eu-west-1
(Ireland) - Lower cost than Frankfurt, excellent connectivity -
eu-central-1 (Frankfurt) - Central Europe, GDPR
compliance
Asia-Pacific (3 regions): -
ap-southeast-1 (Singapore) - Southeast Asia hub -
ap-south-1 (Mumbai) - India coverage -
ap-northeast-1 (Tokyo) - Japan/East Asia
Africa (1 region): - af-south-1 (Cape
Town) - African continent
Estimated Cost: ~$555/month for moderate traffic - Each region: ~$50-65/month (API Gateway + Lambda + DynamoDB replica) - CloudFront: ~$100/month (global CDN, not per-region) - Cognito: $0-50/month (centralized)
Option 2: Maximum Coverage - All 34 AWS Regions
For ultra-low latency everywhere (<30ms to nearest API endpoint):
Deploy API Gateway + Lambda to all 34 commercial regions, including: - North America: us-east-1, us-east-2, us-west-1, us-west-2, ca-central-1, ca-west-1 (Calgary), mx-central-1 (Mexico) - South America: sa-east-1 (São Paulo) - Europe: eu-west-1, eu-west-2, eu-west-3, eu-central-1, eu-central-2 (Zurich), eu-north-1, eu-south-1, eu-south-2 (Spain) - Asia-Pacific: ap-southeast-1, ap-southeast-2, ap-southeast-3 (Jakarta), ap-southeast-4 (Melbourne), ap-southeast-5 (Malaysia), ap-southeast-6 (New Zealand), ap-southeast-7 (Thailand), ap-northeast-1, ap-northeast-2, ap-northeast-3, ap-south-1, ap-south-2 (Hyderabad), ap-east-1 (Hong Kong), ap-east-2 (Taipei) - Middle East: me-south-1 (Bahrain), me-central-1 (UAE) - Africa: af-south-1 (Cape Town) - Israel: il-central-1 (Tel Aviv)
Hybrid Architecture for Cost Optimization: - API Gateway + Lambda: All 34 regions (~$50/region/month) - DynamoDB Global Tables: 12-15 strategic regions (max 20 replicas supported) - CloudFront: Global (single deployment) - Cognito: Single region with Global Accelerator (see below)
Estimated Cost: ~$1,700-2,200/month - API endpoints (34 regions × $50): ~$1,700/month - DynamoDB replicas (12 regions × $25-40): ~$300-500/month - CloudFront: ~$100/month - Cognito + Global Accelerator: ~$50-100/month
When to choose Option 2: - Financial services requiring <30ms latency globally - Gaming platforms with real-time multiplayer - IoT applications with millions of global devices - SaaS serving enterprise customers in 50+ countries
Cognito Multi-Region Strategy
Critical Limitation: Amazon Cognito User Pools are region-locked with no native multi-region replication.
Design Decision: Passwordless vs Password-Based Auth
Before choosing your Cognito architecture, decide on authentication method:
Passwordless Authentication (Recommended for Security): - ✅ No password reuse attacks - users can't reuse compromised passwords - ✅ No credential stuffing - stolen password databases useless - ✅ No phishing - magic links expire, can't be reused - ✅ Better UX - no "forgot password" flows - ✅ Multi-region Cognito works - no password sync issues - ✅ Compliance friendly - many regulations prefer passwordless (PSD2, FIDO2)
Password-Based Authentication: - ✅ Familiar UX - users understand username/password - ❌ Password reuse risk - 65% of users reuse passwords - ❌ Phishing attacks - passwords can be stolen - ❌ Multi-region Cognito doesn't work - can't sync password hashes
Recommendation: Use passwordless authentication for better security and multi-region capability.
Option 1: Single Region + Global Accelerator (Password-Based Auth)
If you must support password-based authentication, deploy one Cognito User Pool in a reliable region with AWS Global Accelerator for low-latency global access.
Architecture:
┌─────────────────────────────────────────────┐
│ Users Worldwide │
└──────────────┬──────────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ AWS Global Accelerator │
│ (Anycast IP, routes to nearest edge) │
└──────────────┬───────────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Cognito User Pool (us-east-2 or eu-west-1) │
│ - User authentication │
│ - JWT token issuance │
│ - Password management │
└──────────────────────────────────────────────┘
Why This Works: 1. Authentication is rare compared to API calls - users authenticate once, then use JWT tokens for hours 2. Cognito APIs are fast - <50ms authentication time globally with Global Accelerator 3. JWT tokens are stateless - API Gateway validates tokens locally without calling Cognito 4. High availability - Cognito has 99.99% SLA, comparable to multi-region setups 5. No password sync issues - avoids the critical limitation of multi-region Cognito
Availability Math: - Cognito SLA: 99.99% (52 minutes downtime/year) - Multi-AZ failover: Automatic within region - Global Accelerator: 99.99% SLA with automatic routing to healthy endpoints - Combined: 99.98% availability (~104 minutes downtime/year)
Cost: $0 for <50,000 monthly active users, then $0.0055/MAU
Active-Active vs Active-Passive Cognito
With passwordless authentication, both active-active and active-passive architectures work well.
Active-Active (Recommended):
Americas: Cognito pool (always accepting auth)
Europe: Cognito pool (always accepting auth)
Asia: Cognito pool (always accepting auth)
- Route53 geo-routing sends users to nearest pool
- All pools active simultaneously
- User syncs to all pools on first auth
- If one region fails, users route to next nearest
Active-Passive:
Primary: Cognito pool in us-east-2 (active)
Backup: Cognito pool in eu-west-1 (standby)
- All users auth in primary normally
- On primary failure, Route53 fails over to backup
- Backup has synced user data (email, attributes)
- Users can authenticate in backup (magic links work)
Why Passwordless Solves the Problem:
# Password-based (DOESN'T WORK):
Primary: user@example.com password_hash_abc123
Backup: user@example.com [password unknown] ❌
# Passwordless (WORKS):
Primary: user@example.com sends magic link authenticated ✅
Backup: user@example.com sends magic link authenticated ✅
Recommendation: Use active-active for better load distribution and lower latency. Active-passive is simpler but wastes the backup pool's capacity.
Option 2: Multi-Region Cognito with Passwordless Auth (Recommended)
Best for: Security-conscious SaaS, compliance requirements, true multi-region resilience.
Deploy separate Cognito pools per continent with passwordless authentication and user synchronization.
Architecture:
Americas: Cognito pool in us-east-2 (magic link auth)
Europe: Cognito pool in eu-west-1 (magic link auth)
Asia: Cognito pool in ap-southeast-1 (magic link auth)
How It Works: 1. User enters email receives magic link 2. Clicks link authenticated in nearest region 3. Post Confirmation Lambda syncs user profile to other regions 4. User can authenticate from any region (no passwords to sync!)
Benefits: - ✅ True multi-region resilience - if one region fails, others work - ✅ Lower latency - auth happens in nearest region (no Global Accelerator needed) - ✅ Better security - no passwords = no password attacks - ✅ Regulatory compliance - auth data stays in-region (GDPR, data residency) - ✅ Simpler failover - users can auth in any region automatically
Requirements: - ⚠️ Passwordless only - magic links, OTP, or federated SSO - ⚠️ API Gateway REST API required (HTTP API doesn't support multi-pool auth) - ⚠️ User sync logic via Post Confirmation Lambda hooks
Implementation:
# Cognito pools in each continent
resource "aws_cognito_user_pool" "americas" {
provider = aws.us_east_2
name = "clouddocs-users-americas"
# Passwordless auth with magic links
username_attributes = ["email"]
# Custom auth flow for magic links
lambda_config {
define_auth_challenge = aws_lambda_function.define_auth.arn
create_auth_challenge = aws_lambda_function.create_auth.arn
verify_auth_challenge_response = aws_lambda_function.verify_auth.arn
post_confirmation = aws_lambda_function.user_sync.arn
}
# Disable password auth
password_policy {
minimum_length = 99 # Effectively disables password signup
}
}
resource "aws_cognito_user_pool" "europe" {
provider = aws.eu_west_1
name = "clouddocs-users-europe"
# Same config as americas
}
resource "aws_cognito_user_pool" "asia" {
provider = aws.ap_southeast_1
name = "clouddocs-users-asia"
# Same config as americas
}
# API Gateway authorizer with multiple Cognito pools
resource "aws_api_gateway_authorizer" "multi_region" {
name = "multi-region-cognito"
type = "COGNITO_USER_POOLS"
provider_arns = [
aws_cognito_user_pool.americas.arn,
aws_cognito_user_pool.europe.arn,
aws_cognito_user_pool.asia.arn
]
}
# User sync Lambda - replicates users across regions
resource "aws_lambda_function" "user_sync" {
function_name = "cognito-user-sync"
runtime = "python3.14"
handler = "handler.handler"
filename = "lambdas/user_sync.zip"
role = aws_iam_role.lambda_cognito.arn
environment {
variables = {
REGIONS_TO_SYNC = "us-east-2,eu-west-1,ap-southeast-1"
}
}
}
User Sync Flow: 1. User enters email on login page 2. Receives magic link via email 3. Clicks link authenticated in nearest Cognito pool 4. Post Confirmation hook triggers 5. Lambda replicates user attributes (email, name, preferences) to other regions 6. User can authenticate from any region going forward 7. On sync failure, queues to SQS for retry (eventually consistent)
Magic Link Implementation (3 Lambda functions required by Cognito Custom Auth):
Cognito Custom Auth Challenge requires three Lambda
functions working together: 1.
define_auth_challenge.py - Controls the auth flow state
machine 2. create_auth_challenge.py - Generates token and
sends magic link 3. verify_auth_challenge.py - Validates
the token the user submits
DynamoDB Token Table (add to your database module):
resource "aws_dynamodb_table" "magic_tokens" {
name = "${var.project_name}-magic-tokens-${var.environment}"
billing_mode = "PAY_PER_REQUEST"
hash_key = "tokenHash"
attribute {
name = "tokenHash"
type = "S"
}
# Auto-delete expired tokens (TTL)
ttl {
attribute_name = "expiresAt"
enabled = true
}
server_side_encryption {
enabled = true
}
tags = {
Name = "${var.project_name}-magic-tokens-${var.environment}"
Environment = var.environment
}
}
1. define_auth_challenge.py (controls the flow state machine):
def handler(event, context):
sessions = event['request']['session']
if len(sessions) == 0:
# First attempt: issue a CUSTOM_CHALLENGE (triggers create_auth_challenge)
event['response']['issueTokens'] = False
event['response']['failAuthentication'] = False
event['response']['challengeName'] = 'CUSTOM_CHALLENGE'
elif (
len(sessions) == 1
and sessions[0]['challengeName'] == 'CUSTOM_CHALLENGE'
and sessions[0]['challengeResult'] is True
):
# User answered correctly: issue JWT tokens
event['response']['issueTokens'] = True
event['response']['failAuthentication'] = False
else:
# Wrong answer or too many attempts: fail
event['response']['issueTokens'] = False
event['response']['failAuthentication'] = True
return event
2. create_auth_challenge.py (generates token, stores it, sends magic link):
import os
import secrets
import hashlib
import time
from datetime import datetime, timezone
from urllib.parse import quote
import boto3
from botocore.exceptions import ClientError
dynamodb = boto3.client('dynamodb')
ses = boto3.client('ses', region_name=os.environ.get('SES_REGION', 'us-east-2'))
TOKENS_TABLE = os.environ['TOKENS_TABLE']
APP_URL = os.environ['APP_URL'] # e.g. https://app.example.com
TOKEN_TTL = 15 * 60 # 15 minutes in seconds
def generate_magic_link(email: str) -> dict:
# 1. Cryptographically secure random token: 32 bytes = 256 bits of entropy
token = secrets.token_hex(32)
# 2. Hash for storage - DB breach can't be used to log in
token_hash = hashlib.sha256(token.encode()).hexdigest()
# 3. Build the magic link URL with raw token as query param
url = f"{APP_URL}/auth/verify?token={token}&email={quote(email)}"
return {'token': token, 'token_hash': token_hash, 'url': url}
def handler(event, context):
# Only generate a new magic link on the first challenge attempt
if len(event['request']['session']) != 0:
return event
email = event['request']['userAttributes']['email']
magic = generate_magic_link(email)
expires_at = int(time.time()) + TOKEN_TTL
# 4. Store the HASHED token in DynamoDB with TTL
try:
dynamodb.put_item(
TableName=TOKENS_TABLE,
Item={
'tokenHash': {'S': magic['token_hash']},
'email': {'S': email},
'used': {'BOOL': False},
'expiresAt': {'N': str(expires_at)},
'createdAt': {'S': datetime.now(timezone.utc).isoformat()},
},
# Prevent overwriting an existing unused token (race condition guard)
ConditionExpression='attribute_not_exists(tokenHash)',
)
except ClientError as e:
if e.response['Error']['Code'] != 'ConditionalCheckFailedException':
raise
# 5. Send the magic link via SES
ses.send_email(
Source='no-reply@example.com',
Destination={'ToAddresses': [email]},
Message={
'Subject': {'Data': 'Sign in to your account'},
'Body': {
'Html': {
'Data': f"""
<p>Click below to sign in. This link expires in 15 minutes.</p>
<p><a href="{magic['url']}">Sign In</a></p>
<p>If you did not request this, you can safely ignore this email.</p>
"""
},
'Text': {
'Data': f"Sign in here (expires in 15 minutes): {magic['url']}"
},
},
},
)
# 6. Pass tokenHash to verify_auth_challenge via private parameters
# (NEVER pass the raw token - private params are not sent to the client)
event['response']['publicChallengeParameters'] = {'email': email}
event['response']['privateChallengeParameters'] = {'tokenHash': magic['token_hash']}
event['response']['challengeMetadata'] = 'MAGIC_LINK'
return event
3. verify_auth_challenge.py (validates the token submitted by the user):
import os
import hashlib
import time
import boto3
from botocore.exceptions import ClientError
dynamodb = boto3.client('dynamodb')
TOKENS_TABLE = os.environ['TOKENS_TABLE']
def handler(event, context):
submitted_token = event['request']['challengeAnswer'] # raw token from URL
stored_hash = event['request']['privateChallengeParameters']['tokenHash']
email = event['request']['userAttributes']['email']
# 1. Hash the submitted token for constant-time comparison
submitted_hash = hashlib.sha256(submitted_token.encode()).hexdigest()
# 2. Hashes must match (timing-safe: both sides are hashes, not raw secrets)
if submitted_hash != stored_hash:
event['response']['answerCorrect'] = False
return event
# 3. Look up the token record in DynamoDB
result = dynamodb.get_item(
TableName=TOKENS_TABLE,
Key={'tokenHash': {'S': stored_hash}},
)
item = result.get('Item')
now = int(time.time())
if (
not item
or item.get('used', {}).get('BOOL', False) # already used replay attack
or int(item.get('expiresAt', {}).get('N', 0)) < now # expired
or item.get('email', {}).get('S') != email # email mismatch tampering
):
event['response']['answerCorrect'] = False
return event
# 4. Mark token as used atomically (one-time use, prevents race conditions)
try:
dynamodb.update_item(
TableName=TOKENS_TABLE,
Key={'tokenHash': {'S': stored_hash}},
UpdateExpression='SET used = :t',
ConditionExpression='used = :f', # only succeed if still unused
ExpressionAttributeValues={
':t': {'BOOL': True},
':f': {'BOOL': False},
},
)
except ClientError as e:
if e.response['Error']['Code'] == 'ConditionalCheckFailedException':
# Another request already consumed this token (race condition)
event['response']['answerCorrect'] = False
return event
raise
event['response']['answerCorrect'] = True
return event
Security Properties of This Implementation: - ✅
Token never stored raw - only SHA-256 hash stored in
DynamoDB - ✅ One-time use - token marked used after
first verification (replay attack prevention) - ✅ TTL enforced
twice - DynamoDB TTL auto-deletes + code checks expiry
timestamp - ✅ Email binding - token only works for the
email it was issued to (prevents token swapping) - ✅ Atomic
mark-as-used - ConditionExpression prevents
concurrent requests consuming same token - ✅ No information
leakage - all failure paths return the same
answerCorrect: False - ✅ 256-bit entropy
- secrets.token_hex(32) is cryptographically unguessable -
✅ No dependencies - uses only Python stdlib + boto3
(pre-installed in Lambda)
Terraform Lambda Resources
(infrastructure/modules/cognito/lambdas.tf):
locals {
lambda_runtime = "python3.14"
lambda_role = aws_iam_role.lambda_cognito.arn
}
resource "aws_lambda_function" "define_auth_challenge" {
function_name = "${var.project_name}-define-auth-challenge-${var.environment}"
runtime = local.lambda_runtime
handler = "handler.handler"
filename = "lambdas/define_auth_challenge.zip"
role = local.lambda_role
}
resource "aws_lambda_function" "create_auth_challenge" {
function_name = "${var.project_name}-create-auth-challenge-${var.environment}"
runtime = local.lambda_runtime
handler = "handler.handler"
filename = "lambdas/create_auth_challenge.zip"
role = local.lambda_role
environment {
variables = {
TOKENS_TABLE = aws_dynamodb_table.magic_tokens.name
APP_URL = var.app_url
SES_REGION = var.aws_region
}
}
}
resource "aws_lambda_function" "verify_auth_challenge" {
function_name = "${var.project_name}-verify-auth-challenge-${var.environment}"
runtime = local.lambda_runtime
handler = "handler.handler"
filename = "lambdas/verify_auth_challenge.zip"
role = local.lambda_role
environment {
variables = {
TOKENS_TABLE = aws_dynamodb_table.magic_tokens.name
}
}
}
# Wire Lambda triggers to Cognito User Pool
resource "aws_cognito_user_pool" "main" {
# ... other config ...
lambda_config {
define_auth_challenge = aws_lambda_function.define_auth_challenge.arn
create_auth_challenge = aws_lambda_function.create_auth_challenge.arn
verify_auth_challenge_response = aws_lambda_function.verify_auth_challenge.arn
post_confirmation = aws_lambda_function.user_sync.arn
}
}
Cost Comparison: - Single Cognito + Global Accelerator: $50-100/month (Global Accelerator cost) - Multi-Region Cognito (3 pools): $0-150/month (3 × $0-50 depending on MAUs) - Winner: Multi-region is actually cheaper at scale (no Global Accelerator needed)
Availability Comparison: - Single Cognito: 99.99% SLA (52 min downtime/year) - Multi-Region Cognito: 99.999% effective (5 min downtime/year, assuming independent failures) - If Americas pool fails Europe/Asia pools still work - Users only affected if their primary region is down
Recommendation for our platform: Use multi-region Cognito with passwordless authentication - ✅ Better security (no password attacks) - ✅ Better availability (true multi-region failover) - ✅ Better compliance (in-region auth data) - ✅ Lower latency (no Global Accelerator hop) - ✅ Lower or equal cost
Why Passwordless Authentication is a Security Best Practice
Real-World Attack Statistics: - 81% of data breaches involve stolen or weak passwords (Verizon DBIR 2023) - 65% of users reuse passwords across multiple sites - Credential stuffing attacks: 193 billion in 2020 alone - Phishing: 90% of breaches start with phishing emails
How Passwordless Prevents These Attacks:
No Credential Stuffing
Attacker has stolen password database from Site A Password-based: Tries same passwords on Site B succeeds ❌ Passwordless: No passwords to steal or reuse attack fails ✅No Phishing
Attacker sends fake login page to steal password Password-based: User enters password attacker captures it ❌ Passwordless: Magic link expires after 15 min, one-time use attack fails ✅No Brute Force
Attacker tries 10,000 common passwords Password-based: Eventually guesses weak password ❌ Passwordless: No password field to attack attack fails ✅No Password Reuse
Password-based: Same password on Gmail, Facebook, your SaaS ❌ Passwordless: Each auth requires fresh magic link isolated ✅
Compliance Benefits: - PSD2 (EU payment services): Recommends strong customer authentication - FIDO2/WebAuthn: Industry standard for passwordless - NIST Guidelines: Recommends against complex password requirements - SOC 2: Passwordless reduces password-related control requirements
User Experience Benefits: - No "forgot password" support tickets (saves $5-15 per ticket) - Faster onboarding (no password strength requirements) - Works across devices (magic link in email) - No password manager needed
Implementation Options:
Magic Links (Email-based)
User: Enter email Check inbox Click link Authenticated Pros: Familiar UX, works everywhere Cons: Email delivery delays (3-10 sec)OTP/SMS (Code-based)
User: Enter phone Receive code Enter code Authenticated Pros: Fast (<5 sec), familiar Cons: SMS cost ($0.0075/msg), SIM swap attacksWebAuthn/Passkeys (Biometric)
User: Click "Sign in" Face ID/Touch ID Authenticated Pros: Instant, most secure, no phishing possible Cons: Requires modern device, complex implementation
Recommendation for our platform: Magic links for initial rollout, add WebAuthn passkeys later for power users.
Why our platform Doesn't Support Password-Based Authentication
Decision: our platform will NOT support traditional username/password authentication.
Rationale:
1. Password Reuse is Rampant - 65% of users reuse passwords across multiple sites - When LinkedIn was breached (6.5M passwords leaked), those passwords were tested on thousands of other sites - Your SaaS inherits security from every other site your users use - Even with strong password policies, users will reuse passwords from less secure sites
2. Passwords Don't Scale with Security Threats
2010: 8-character password was "strong"
2015: 12-character password with symbols required
2020: 16-character password with complexity rules
2025: Still getting breached because users write them down or reuse them
3. Support Cost is High - "Forgot password" is the #1 support ticket type (30-40% of all tickets) - Average cost per password reset: $70 (Forrester Research) - For 10,000 users, expect 3,000-4,000 password resets per year = $210k-280k in support costs
4. Compliance Burden - Password storage requirements (bcrypt with cost factor 12+, salting, etc.) - Password rotation policies (NIST now says these are counterproductive) - Breach notification requirements if password database compromised - PCI-DSS, SOC 2, ISO 27001 all have extensive password controls
5. Multi-Region Deployment Incompatibility - Cognito doesn't expose password hashes (by design for security) - Can't sync passwords across regions - Forces single-region deployment worse availability
Real-World Example:
Dropbox (2012): 68 million passwords stolen
Result: Passwords tested on 1000s of sites
Impact: Your users' accounts compromised even if YOUR security is perfect
our platform Approach: Eliminate passwords entirely eliminate password-related breaches.
Why our platform Doesn't Support SMS OTP
Decision: our platform will NOT support SMS-based one-time passwords.
Rationale:
1. SIM Swap Attacks are Trivial
How SIM Swap Works:
Step 1: Attacker calls mobile carrier pretending to be victim
"I lost my phone, please transfer my number to new SIM"
Step 2: Carrier transfers number to attacker's SIM card
(Often just requires knowing SSN or answering security questions)
Step 3: Attacker receives victim's SMS messages
Including OTP codes for authentication
Step 4: Attacker logs into victim's accounts
Banks, crypto exchanges, SaaS platforms, everything
Total time: 15-30 minutes
Cost to attacker: $0 (social engineering only)
Real-World SIM Swap Attacks:
- Reddit CEO (2018): Coinbase account hacked via SIM swap, despite SMS 2FA
- Twitter Employees (2020): 130 high-profile accounts compromised via SIM swap
- Michael Terpin (2019): Lost $24 million in cryptocurrency via SIM swap attack
- Jack Dorsey (2019): Twitter CEO's own account hacked via SIM swap
FBI Warning (2022): "Criminals are increasingly using SIM swapping to steal millions from victims' bank and cryptocurrency accounts."
2. SS7 Protocol Vulnerabilities
The global telecom network (SS7) has fundamental security flaws:
Vulnerability: SMS messages route through unsecured SS7 network
Attack: Intercept SMS messages without SIM swap
Cost: $1,000-5,000 to access SS7 network (available on dark web)
Target: Anyone, anywhere in the world
Documented Cases: - German banks (2017): Attackers drained accounts using SS7 to intercept SMS 2FA - UK banks (2018): Similar SS7-based attacks documented
3. Mobile Carriers Have Poor Security
Carrier Social Engineering Success Rate: - Studies show 60-80% success rate for SIM swap social engineering - Requirements vary by carrier: - Some: Just name + partial SSN - Others: Birthday + billing address (easily found online) - Worst: No verification at all
Why Carriers Don't Fix It: - Customer service prioritizes convenience over security - Call center employees poorly trained on security protocols - No financial liability for carriers when SIM swap causes account compromise
4. Regulatory Warnings
NIST (2016): "SMS should not be used for out-of-band authentication" - NIST Special Publication 800-63B explicitly discourages SMS - Recommends hardware tokens or software authenticators instead
FCC (2023): Issued warnings about SIM swapping, but no mandatory carrier protections
5. Cost vs Security Trade-off
SMS OTP Cost:
- Twilio: $0.0079 per SMS
- For 100k monthly authentications: $790/month = $9,480/year
Email Magic Links Cost:
- AWS SES: $0.10 per 1,000 emails
- For 100k monthly authentications: $10/month = $120/year
Savings: $9,360/year while being MORE secure
our platform Approach: - Primary: Email-based magic links (secure, cheap, no SIM swap risk) - Future: WebAuthn/Passkeys (most secure, no phishing possible) - Never: SMS OTP (expensive, insecure, false sense of security)
Security Hierarchy: What our platform Supports
Tier 1: WebAuthn/Passkeys (Future, Most Secure) - Face ID, Touch ID, Windows Hello - Phishing impossible (cryptographic challenge-response) - No shared secrets (private key never leaves device) - FIDO2 standard
Tier 2: Email Magic Links (Current, Secure) - One-time use links, expire after 15 minutes - Requires email account compromise (much harder than SIM swap) - No SIM swap vulnerability - No SS7 vulnerability
Tier 3: Authenticator Apps (TOTP) (Maybe Future) - Google Authenticator, Authy, 1Password - Time-based codes, no SMS involved - Still vulnerable to phishing, but better than SMS
NOT SUPPORTED: - ❌ Passwords (reuse, brute force, phishing) - ❌ SMS OTP (SIM swap, SS7 attacks) - ❌ Security questions (publicly available information)
Trade-off Acknowledgment:
Email magic links require:
- Users have email access (99%+ of SaaS users do)
- 3-10 second delay for email delivery
- Email provider security (Gmail, Outlook more secure than SMS)
We accept this trade-off because:
- Email compromise is significantly harder than SIM swap
- Email providers (Google, Microsoft) have better security than telcos
- Cost savings ($9k+/year) can be invested in other security measures
Why Global Accelerator Solves Latency (Password-Based Only)
Note: Only needed if using single-region Cognito with password-based auth. Multi-region passwordless Cognito doesn't need Global Accelerator.
AWS Global Accelerator uses anycast IP addresses that route users to the nearest AWS edge location (200+ globally). From there, traffic travels over AWS's private backbone network to Cognito:
- Without Global Accelerator: User Public Internet Cognito (~150-300ms globally)
- With Global Accelerator: User Nearest Edge (5-20ms) AWS Backbone Cognito (~30-80ms globally)
Result: 50-70% latency reduction for authentication calls.
Prerequisites
- AWS Account with appropriate permissions
- Terraform >= 1.5
- Node.js >= 18 (for Lambda functions)
- AWS CLI configured
- Basic understanding of React/Next.js (for frontend)
Project Structure
global-web-app/
├── infrastructure/ # Terraform code
│ ├── modules/
│ │ ├── networking/ # VPC, subnets (optional)
│ │ ├── cognito/ # User authentication
│ │ ├── api-gateway/ # API Gateway + Lambda
│ │ ├── database/ # DynamoDB tables
│ │ ├── storage/ # S3 buckets
│ │ ├── cdn/ # CloudFront distribution
│ │ └── monitoring/ # CloudWatch alarms & logs
│ ├── environments/
│ │ ├── dev.tfvars
│ │ ├── staging.tfvars
│ │ └── prod.tfvars
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
├── backend/ # Lambda functions
│ ├── src/
│ │ ├── auth/ # Authentication handlers
│ │ ├── users/ # User CRUD operations
│ │ ├── files/ # File upload handlers
│ │ └── shared/ # Shared utilities
│ ├── tests/
│ └── package.json
├── frontend/ # React/Next.js application
│ ├── src/
│ │ ├── components/
│ │ ├── pages/
│ │ ├── contexts/ # Auth context
│ │ └── lib/ # API client
│ └── package.json
└── README.md
Part 1: Infrastructure Setup with Terraform
1.1 DynamoDB Tables
Let's start with our data layer. We'll create tables for users, leaderboards, and activity tracking.
Critical: Don't
Use Cognito sub as Your User ID
The Trap (credit: Yan Cui):
Using Cognito's sub (subject) as your primary user ID
seems convenient, but it's a major architectural
mistake:
Problem 1: Subs don't survive migration
User in Pool A: sub = "a1b2c3d4-..."
Migrate to Pool B: sub = "x9y8z7w6-..." (DIFFERENT!)
Your database: Still references old sub user data orphaned ❌
Problem 2: Subs multiply with federation
User signs in with Google: sub = "google_abc123"
Same user via LinkedIn: sub = "linkedin_xyz789"
Same user via email: sub = "email_def456"
Result: 3 different user records for 1 person ❌
The Solution: Generate Your Own User ID
- Create custom userId - Generate time-sortable, globally unique ID
- Store in DynamoDB - Your single source of truth
- Link to Cognito - Store userId as custom attribute in Cognito
- Never depend on sub - Use your custom userId everywhere
Choosing Your User ID Format: UUIDv4 vs UUIDv7 vs ULID
Comparison Table:
| Feature | UUIDv4 | UUIDv7 | ULID | Winner |
|---|---|---|---|---|
| Format | 550e8400-e29b-41d4-a716-446655440000 |
0188f3e9-7b4a-7890-a456-426614174000 |
01ARZ3NDEKTSV4RRFFQ69G5FAV |
ULID (no hyphens) |
| Length | 36 chars (with hyphens) | 36 chars (with hyphens) | 26 chars | ULID ✅ |
| Time-ordered | ❌ Random | ✅ First 48 bits = timestamp | ✅ First 48 bits = timestamp | UUIDv7/ULID ✅ |
| Sortable | ❌ No | ⚠️ Binary sortable | ✅ Lexicographically sortable | ULID ✅ |
| DynamoDB range queries | ❌ Can't query by time | ⚠️ Requires binary comparison | ✅ String comparison works | ULID ✅ |
| URL-safe | ❌ Hyphens need encoding | ❌ Hyphens need encoding | ✅ Base32, no special chars | ULID ✅ |
| Case-sensitive | ✅ Lowercase hex | ✅ Lowercase hex | ✅ Uppercase base32 | All ✅ |
| Collision resistance | Highest (122 random bits) | High (74 random bits) | High (80 random bits) | UUIDv4 |
| Human-readable | ❌ Hard to read | ❌ Hard to read | ✅ Easier (base32) | ULID ✅ |
| DynamoDB hot partitions | ✅ Well distributed | ✅ Well distributed (ms precision) | ✅ Well distributed (ms precision) | All ✅ |
| Standard | RFC 4122 (1996) | RFC 9562 (2024) | Spec (2016) | UUIDv7 (newest) |
Detailed Analysis:
1. UUIDv4 (Random):
import { v4 as uuidv4 } from 'uuid';
const userId = uuidv4();
// "550e8400-e29b-41d4-a716-446655440000"
- ✅ Maximum collision resistance - 122 random bits
- ✅ Well distributed in DynamoDB (no hot partitions)
- ❌ Not time-ordered - can't query users by creation time
- ❌ Not sortable - random ordering
- ❌ 36 characters with hyphens (longer)
When to use: Legacy systems, migration from existing UUID systems
2. UUIDv7 (Time-ordered, newest RFC):
import { v7 as uuidv7 } from 'uuid';
const userId = uuidv7();
// "0188f3e9-7b4a-7890-a456-426614174000"
// First 48 bits = Unix timestamp (ms)
- ✅ Time-ordered - first 48 bits = Unix timestamp in milliseconds
- ✅ RFC standard - newest UUID version (RFC 9562, August 2024)
- ✅ Good collision resistance - 74 random bits
- ⚠️ Binary sortable only - DynamoDB sorts as strings, not binary
- ❌ DynamoDB range queries don't work - string comparison differs from binary
- ❌ 36 characters with hyphens
When to use: Systems requiring RFC compliance, migrating from UUIDv4
3. ULID (Time-ordered, lexicographically sortable):
import { ulid } from 'ulid';
const userId = ulid();
// "01ARZ3NDEKTSV4RRFFQ69G5FAV"
// Format: TTTTTTTTTTRRRRRRRRRRRRRRR
// T = Timestamp (48 bits, 10 chars)
// R = Randomness (80 bits, 16 chars)
- ✅ Lexicographically sortable - works with DynamoDB string sorting
- ✅ Time-ordered - first 48 bits = Unix timestamp in milliseconds
- ✅ DynamoDB range queries work -
userId BETWEEN '01ARZ3ND...' AND '01ARZ4AA...' - ✅ Compact - 26 characters (no hyphens, base32)
- ✅ URL-safe - no special characters, no encoding needed
- ✅ Case-insensitive - uppercase only (base32:
0-9A-V) - ✅ Human-readable - easier to read than hex UUIDs
- ✅ Good collision resistance - 80 random bits (1.21e24 IDs per millisecond)
When to use: New DynamoDB applications (recommended) ✅
DynamoDB-Specific Considerations:
Why ULID Wins for DynamoDB:
// Query users created in last 24 hours (ULID - WORKS)
const yesterday = ulid(Date.now() - 86400000); // Generate ULID for 24h ago
const result = await dynamodb.query({
TableName: 'users',
KeyConditionExpression: 'userId >= :yesterday',
ExpressionAttributeValues: { ':yesterday': yesterday }
});
// Query users created in last 24 hours (UUIDv4 - DOESN'T WORK)
// Can't query by time because UUIDs are random
// Query users created in last 24 hours (UUIDv7 - DOESN'T WORK)
// Binary sortable but DynamoDB sorts as strings, not binary
ULID Sorting in DynamoDB:
// ULIDs sorted lexicographically = sorted by time
const users = [
'01ARZ3NDEKTSV4RRFFQ69G5FAV', // Earlier
'01ARZ3NDEKTTT5RRFFQ69G5FAV', // Same millisecond, different random
'01ARZ3NDELTTT5RRFFQ69G5FAV', // Same millisecond, different random
'01ARZ4AAEKTSV4RRFFQ69G5FAV' // Later (next millisecond)
];
// In DynamoDB, these naturally sort by creation time
// UUIDv4 would be random order
// UUIDv7 would NOT sort correctly as strings
Hot Partition Avoidance:
// ULID: 1ms precision + random = good distribution
// 1,000 writes/second = 1 ULID per millisecond = distributed across partitions
// If >1,000 writes/ms (unlikely):
// Multiple ULIDs per ms still have random suffix distribution maintained
Recommendation for DynamoDB: Use ULID ✅
Implementation:
// package.json
{
"dependencies": {
"ulid": "^2.3.0"
}
}
// Lambda function
import { ulid } from 'ulid';
export const handler = async (event: CognitoPostConfirmationEvent) => {
const userId = ulid(); // "01ARZ3NDEKTSV4RRFFQ69G5FAV"
// Store in DynamoDB
await dynamodb.putItem({
TableName: 'users',
Item: {
userId: { S: userId },
email: { S: event.request.userAttributes.email },
cognitoSub: { S: event.request.userAttributes.sub },
createdAt: { S: new Date().toISOString() }
}
});
// Update Cognito custom attribute
await cognito.adminUpdateUserAttributes({
UserPoolId: event.userPoolId,
Username: event.userName,
UserAttributes: [
{ Name: 'custom:userId', Value: userId }
]
});
return event;
};
Migration Path (if already using UUIDs):
// For existing users: keep their current ID (UUIDv4 or v7)
// For new users: generate ULID
const userId = existingUser?.id || ulid();
// Gradually migrate old users to ULID over time (optional)
User Creation Flow:
1. User signs up via Cognito
2. Cognito Post Confirmation trigger fires
3. Lambda generates userId: ulid() "01HQXYZ123ABC456..."
4. Lambda creates DynamoDB user record
5. Lambda updates Cognito custom attribute: custom:userId
6. Frontend receives JWT with custom:userId claim
7. All API calls use custom userId (not sub)
Migration Strategy (if already using sub):
// For existing users, set userId = their current sub
// For new users, generate fresh userId
const userId = existingUser ? user.sub : ulid();
// Store userId in Cognito custom attribute
await cognito.adminUpdateUserAttributes({
UserPoolId: poolId,
Username: user.sub,
UserAttributes: [{ Name: 'custom:userId', Value: userId }]
});
infrastructure/modules/database/main.tf:
# Users table - stores user profiles and attributes
# CRITICAL: Uses custom userId, NOT Cognito sub
resource "aws_dynamodb_table" "users" {
name = "${var.project_name}-users-${var.environment}"
billing_mode = "PAY_PER_REQUEST" # Serverless pricing
hash_key = "userId" # Our custom user ID (ULID)
attribute {
name = "userId"
type = "S"
}
attribute {
name = "email"
type = "S"
}
attribute {
name = "cognitoSub"
type = "S"
}
# GSI for email lookups (user searches)
global_secondary_index {
name = "email-index"
hash_key = "email"
projection_type = "ALL"
}
# GSI for Cognito sub lookups (during authentication)
# Allows mapping from Cognito sub our userId
global_secondary_index {
name = "cognitoSub-index"
hash_key = "cognitoSub"
projection_type = "ALL"
}
# Enable point-in-time recovery
point_in_time_recovery {
enabled = true
}
# Enable encryption at rest
server_side_encryption {
enabled = true
}
# Global table configuration for multi-region
replica {
region_name = "eu-west-1"
}
replica {
region_name = "ap-southeast-1"
}
tags = {
Name = "${var.project_name}-users-${var.environment}"
Environment = var.environment
}
}
# Leaderboard table - global rankings updated in real-time
resource "aws_dynamodb_table" "leaderboard" {
name = "${var.project_name}-leaderboard-${var.environment}"
billing_mode = "PAY_PER_REQUEST"
hash_key = "leaderboardId" # e.g., "global", "us-east-2", "monthly"
range_key = "score#userId" # Composite: score for sorting, userId for uniqueness
attribute {
name = "leaderboardId"
type = "S"
}
attribute {
name = "score#userId"
type = "S" # Format: "0000001234#01HQXYZ..." (zero-padded score for sorting)
}
attribute {
name = "userId"
type = "S"
}
# GSI for user's position across leaderboards
global_secondary_index {
name = "userId-index"
hash_key = "userId"
projection_type = "ALL"
}
# TTL for temporary leaderboards (daily, weekly)
ttl {
attribute_name = "expiresAt"
enabled = true
}
point_in_time_recovery {
enabled = true
}
server_side_encryption {
enabled = true
}
# Global table for real-time leaderboard sync
replica {
region_name = "eu-west-1"
}
replica {
region_name = "ap-southeast-1"
}
tags = {
Name = "${var.project_name}-leaderboard-${var.environment}"
Environment = var.environment
}
}
# Sessions table for refresh tokens (optional if using Cognito)
resource "aws_dynamodb_table" "sessions" {
name = "${var.project_name}-sessions-${var.environment}"
billing_mode = "PAY_PER_REQUEST"
hash_key = "sessionId"
attribute {
name = "sessionId"
type = "S"
}
# TTL for automatic session cleanup
ttl {
attribute_name = "expiresAt"
enabled = true
}
point_in_time_recovery {
enabled = true
}
server_side_encryption {
enabled = true
}
tags = {
Name = "${var.project_name}-sessions-${var.environment}"
Environment = var.environment
}
}
1.2 Cognito User Pool
infrastructure/modules/cognito/main.tf:
resource "aws_cognito_user_pool" "main" {
name = "${var.project_name}-users-${var.environment}"
# Username configuration
username_attributes = ["email"]
auto_verified_attributes = ["email"]
# Password policy
password_policy {
minimum_length = 12
require_lowercase = true
require_numbers = true
require_symbols = true
require_uppercase = true
temporary_password_validity_days = 7
}
# MFA configuration
mfa_configuration = "OPTIONAL"
software_token_mfa_configuration {
enabled = true
}
# Account recovery
account_recovery_setting {
recovery_mechanism {
name = "verified_email"
priority = 1
}
}
# User attributes
schema {
name = "email"
attribute_data_type = "String"
required = true
mutable = false
string_attribute_constraints {
min_length = 5
max_length = 320
}
}
schema {
name = "name"
attribute_data_type = "String"
required = true
mutable = true
string_attribute_constraints {
min_length = 1
max_length = 256
}
}
# Custom attribute for user role
schema {
name = "role"
attribute_data_type = "String"
mutable = true
string_attribute_constraints {
min_length = 1
max_length = 20
}
}
# Email verification
verification_message_template {
default_email_option = "CONFIRM_WITH_LINK"
email_subject = "Verify your email"
email_message = "Please click the link below to verify your email: {##Verify Email##}"
}
# Lambda triggers
lambda_config {
pre_sign_up = var.pre_signup_lambda_arn
post_confirmation = var.post_confirmation_lambda_arn
pre_token_generation = var.pre_token_generation_lambda_arn
}
# Advanced security
user_pool_add_ons {
advanced_security_mode = "ENFORCED"
}
tags = {
Name = "${var.project_name}-user-pool-${var.environment}"
Environment = var.environment
}
}
# App client for web application
resource "aws_cognito_user_pool_client" "web_client" {
name = "${var.project_name}-web-client-${var.environment}"
user_pool_id = aws_cognito_user_pool.main.id
# OAuth configuration
allowed_oauth_flows = ["code"]
allowed_oauth_scopes = ["email", "openid", "profile"]
allowed_oauth_flows_user_pool_client = true
callback_urls = var.callback_urls
logout_urls = var.logout_urls
# Token validity
id_token_validity = 60 # minutes
access_token_validity = 60 # minutes
refresh_token_validity = 30 # days
token_validity_units {
id_token = "minutes"
access_token = "minutes"
refresh_token = "days"
}
# Security
prevent_user_existence_errors = "ENABLED"
# Explicit auth flows
explicit_auth_flows = [
"ALLOW_USER_SRP_AUTH",
"ALLOW_REFRESH_TOKEN_AUTH",
"ALLOW_USER_PASSWORD_AUTH"
]
# Read/write attributes
read_attributes = [
"email",
"email_verified",
"name",
"custom:role"
]
write_attributes = [
"email",
"name"
]
}
# User pool domain for hosted UI
resource "aws_cognito_user_pool_domain" "main" {
domain = "${var.project_name}-${var.environment}"
user_pool_id = aws_cognito_user_pool.main.id
}
# Identity pool for AWS credentials (for S3 uploads)
resource "aws_cognito_identity_pool" "main" {
identity_pool_name = "${var.project_name}_identity_pool_${var.environment}"
allow_unauthenticated_identities = false
cognito_identity_providers {
client_id = aws_cognito_user_pool_client.web_client.id
provider_name = aws_cognito_user_pool.main.endpoint
}
}
# IAM role for authenticated users
resource "aws_iam_role" "authenticated" {
name = "${var.project_name}-cognito-authenticated-${var.environment}"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Federated = "cognito-identity.amazonaws.com"
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"cognito-identity.amazonaws.com:aud" = aws_cognito_identity_pool.main.id
}
"ForAnyValue:StringLike" = {
"cognito-identity.amazonaws.com:amr" = "authenticated"
}
}
}]
})
}
# Policy for authenticated users - S3 upload access
resource "aws_iam_role_policy" "authenticated" {
name = "authenticated-user-policy"
role = aws_iam_role.authenticated.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject"
]
Resource = "${var.user_files_bucket_arn}/users/$${cognito-identity.amazonaws.com:sub}/*"
}
]
})
}
# Attach role to identity pool
resource "aws_cognito_identity_pool_roles_attachment" "main" {
identity_pool_id = aws_cognito_identity_pool.main.id
roles = {
"authenticated" = aws_iam_role.authenticated.arn
}
}
1.3 S3 Buckets
infrastructure/modules/storage/main.tf:
# User files bucket
resource "aws_s3_bucket" "user_files" {
bucket = "${var.project_name}-user-files-${var.environment}"
tags = {
Name = "${var.project_name}-user-files-${var.environment}"
Environment = var.environment
}
}
# Enable versioning
resource "aws_s3_bucket_versioning" "user_files" {
bucket = aws_s3_bucket.user_files.id
versioning_configuration {
status = "Enabled"
}
}
# Server-side encryption
resource "aws_s3_bucket_server_side_encryption_configuration" "user_files" {
bucket = aws_s3_bucket.user_files.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
# Block public access
resource "aws_s3_bucket_public_access_block" "user_files" {
bucket = aws_s3_bucket.user_files.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# Lifecycle rules
resource "aws_s3_bucket_lifecycle_configuration" "user_files" {
bucket = aws_s3_bucket.user_files.id
rule {
id = "transition-to-ia"
status = "Enabled"
transition {
days = 30
storage_class = "STANDARD_IA"
}
transition {
days = 90
storage_class = "GLACIER"
}
expiration {
days = 365
}
}
rule {
id = "delete-old-versions"
status = "Enabled"
noncurrent_version_expiration {
noncurrent_days = 90
}
}
}
# CORS configuration for uploads from browser
resource "aws_s3_bucket_cors_configuration" "user_files" {
bucket = aws_s3_bucket.user_files.id
cors_rule {
allowed_headers = ["*"]
allowed_methods = ["PUT", "POST", "GET"]
allowed_origins = var.allowed_origins
expose_headers = ["ETag"]
max_age_seconds = 3000
}
}
# Static website bucket
resource "aws_s3_bucket" "website" {
bucket = "${var.project_name}-website-${var.environment}"
tags = {
Name = "${var.project_name}-website-${var.environment}"
Environment = var.environment
}
}
resource "aws_s3_bucket_website_configuration" "website" {
bucket = aws_s3_bucket.website.id
index_document {
suffix = "index.html"
}
error_document {
key = "404.html"
}
}
Part 2: Lambda Functions
2.1 Authentication Handler
backend/src/auth/login.ts:
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { CognitoIdentityProviderClient, InitiateAuthCommand } from '@aws-sdk/client-cognito-identity-provider';
const cognitoClient = new CognitoIdentityProviderClient({ region: process.env.AWS_REGION });
export const handler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
try {
const body = JSON.parse(event.body || '{}');
const { email, password } = body;
if (!email || !password) {
return {
statusCode: 400,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': process.env.CORS_ORIGIN || '*',
},
body: JSON.stringify({ error: 'Email and password are required' }),
};
}
const command = new InitiateAuthCommand({
AuthFlow: 'USER_PASSWORD_AUTH',
ClientId: process.env.COGNITO_CLIENT_ID!,
AuthParameters: {
USERNAME: email,
PASSWORD: password,
},
});
const response = await cognitoClient.send(command);
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': process.env.CORS_ORIGIN || '*',
},
body: JSON.stringify({
accessToken: response.AuthenticationResult?.AccessToken,
idToken: response.AuthenticationResult?.IdToken,
refreshToken: response.AuthenticationResult?.RefreshToken,
expiresIn: response.AuthenticationResult?.ExpiresIn,
}),
};
} catch (error: any) {
console.error('Login error:', error);
return {
statusCode: error.statusCode || 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': process.env.CORS_ORIGIN || '*',
},
body: JSON.stringify({
error: error.message || 'An error occurred during login',
}),
};
}
};
2.2 User CRUD Operations
backend/src/users/get-user.ts:
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb';
import { verifyToken } from '../shared/auth';
const client = new DynamoDBClient({ region: process.env.AWS_REGION });
const docClient = DynamoDBDocumentClient.from(client);
export const handler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
try {
// Verify JWT token
const claims = await verifyToken(event.headers.Authorization || '');
if (!claims) {
return {
statusCode: 401,
body: JSON.stringify({ error: 'Unauthorized' }),
};
}
const userId = event.pathParameters?.userId || claims.sub;
const command = new GetCommand({
TableName: process.env.USERS_TABLE!,
Key: { userId },
});
const result = await docClient.send(command);
if (!result.Item) {
return {
statusCode: 404,
body: JSON.stringify({ error: 'User not found' }),
};
}
// Remove sensitive fields
const { passwordHash, ...user } = result.Item;
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': process.env.CORS_ORIGIN || '*',
},
body: JSON.stringify(user),
};
} catch (error: any) {
console.error('Get user error:', error);
return {
statusCode: 500,
body: JSON.stringify({ error: 'Internal server error' }),
};
}
};
2.3 File Upload Handler
backend/src/files/generate-upload-url.ts:
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { v4 as uuidv4 } from 'uuid';
import { verifyToken } from '../shared/auth';
const s3Client = new S3Client({ region: process.env.AWS_REGION });
const dynamoClient = DynamoDBDocumentClient.from(new DynamoDBClient({ region: process.env.AWS_REGION }));
export const handler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
try {
const claims = await verifyToken(event.headers.Authorization || '');
if (!claims) {
return {
statusCode: 401,
body: JSON.stringify({ error: 'Unauthorized' }),
};
}
const body = JSON.parse(event.body || '{}');
const { fileName, fileType, fileSize } = body;
// Validate file size (max 100MB)
if (fileSize > 100 * 1024 * 1024) {
return {
statusCode: 400,
body: JSON.stringify({ error: 'File size exceeds 100MB limit' }),
};
}
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf', 'text/plain'];
if (!allowedTypes.includes(fileType)) {
return {
statusCode: 400,
body: JSON.stringify({ error: 'File type not allowed' }),
};
}
const fileId = uuidv4();
const userId = claims.sub;
const key = `users/${userId}/${fileId}/${fileName}`;
// Generate pre-signed URL for upload
const command = new PutObjectCommand({
Bucket: process.env.USER_FILES_BUCKET!,
Key: key,
ContentType: fileType,
Metadata: {
userId,
originalFileName: fileName,
},
});
const uploadUrl = await getSignedUrl(s3Client, command, { expiresIn: 300 }); // 5 minutes
// Store file metadata in DynamoDB
const now = Date.now();
await dynamoClient.send(new PutCommand({
TableName: process.env.FILES_TABLE!,
Item: {
fileId,
userId,
fileName,
fileType,
fileSize,
s3Key: key,
status: 'pending',
uploadedAt: now,
expiresAt: now + (365 * 24 * 60 * 60 * 1000), // 1 year TTL
},
}));
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': process.env.CORS_ORIGIN || '*',
},
body: JSON.stringify({
fileId,
uploadUrl,
expiresIn: 300,
}),
};
} catch (error: any) {
console.error('Generate upload URL error:', error);
return {
statusCode: 500,
body: JSON.stringify({ error: 'Internal server error' }),
};
}
};
Part 3: API Gateway Configuration
infrastructure/modules/api-gateway/main.tf:
resource "aws_apigatewayv2_api" "main" {
name = "${var.project_name}-api-${var.environment}"
protocol_type = "HTTP"
cors_configuration {
allow_origins = var.allowed_origins
allow_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
allow_headers = ["Content-Type", "Authorization"]
max_age = 300
}
}
# Authorizer using Cognito
resource "aws_apigatewayv2_authorizer" "cognito" {
api_id = aws_apigatewayv2_api.main.id
authorizer_type = "JWT"
identity_sources = ["$request.header.Authorization"]
name = "cognito-authorizer"
jwt_configuration {
audience = [var.cognito_client_id]
issuer = "https://cognito-idp.${var.aws_region}.amazonaws.com/${var.cognito_user_pool_id}"
}
}
# Routes
resource "aws_apigatewayv2_route" "auth_login" {
api_id = aws_apigatewayv2_api.main.id
route_key = "POST /auth/login"
target = "integrations/${aws_apigatewayv2_integration.auth_login.id}"
}
resource "aws_apigatewayv2_route" "users_get" {
api_id = aws_apigatewayv2_api.main.id
route_key = "GET /users/{userId}"
target = "integrations/${aws_apigatewayv2_integration.users_get.id}"
authorization_type = "JWT"
authorizer_id = aws_apigatewayv2_authorizer.cognito.id
}
# Stage
resource "aws_apigatewayv2_stage" "main" {
api_id = aws_apigatewayv2_api.main.id
name = var.environment
auto_deploy = true
access_log_settings {
destination_arn = aws_cloudwatch_log_group.api_gateway.arn
format = jsonencode({
requestId = "$context.requestId"
ip = "$context.identity.sourceIp"
requestTime = "$context.requestTime"
httpMethod = "$context.httpMethod"
routeKey = "$context.routeKey"
status = "$context.status"
protocol = "$context.protocol"
responseLength = "$context.responseLength"
errorMessage = "$context.error.message"
})
}
default_route_settings {
throttling_burst_limit = 5000
throttling_rate_limit = 10000
}
}
Part 4: CloudFront Distribution
infrastructure/modules/cdn/main.tf:
resource "aws_cloudfront_distribution" "main" {
enabled = true
is_ipv6_enabled = true
comment = "${var.project_name} - ${var.environment}"
default_root_object = "index.html"
price_class = "PriceClass_All" # Global distribution
# S3 origin for static website
origin {
domain_name = var.website_bucket_regional_domain_name
origin_id = "S3-Website"
s3_origin_config {
origin_access_identity = aws_cloudfront_origin_access_identity.main.cloudfront_access_identity_path
}
}
# API Gateway origin
origin {
domain_name = replace(var.api_gateway_endpoint, "/^https?://([^/]*).*/", "$1")
origin_id = "API"
custom_origin_config {
http_port = 80
https_port = 443
origin_protocol_policy = "https-only"
origin_ssl_protocols = ["TLSv1.2"]
}
origin_path = "/${var.environment}"
}
# Default cache behavior - static website
default_cache_behavior {
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "S3-Website"
forwarded_values {
query_string = false
cookies {
forward = "none"
}
}
viewer_protocol_policy = "redirect-to-https"
min_ttl = 0
default_ttl = 3600
max_ttl = 86400
compress = true
}
# API cache behavior
ordered_cache_behavior {
path_pattern = "/api/*"
allowed_methods = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "API"
forwarded_values {
query_string = true
headers = ["Authorization", "Content-Type"]
cookies {
forward = "all"
}
}
viewer_protocol_policy = "https-only"
min_ttl = 0
default_ttl = 0
max_ttl = 0
compress = true
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
cloudfront_default_certificate = true
minimum_protocol_version = "TLSv1.2_2021"
}
custom_error_response {
error_code = 404
response_code = 200
response_page_path = "/index.html"
}
custom_error_response {
error_code = 403
response_code = 200
response_page_path = "/index.html"
}
tags = {
Name = "${var.project_name}-cdn-${var.environment}"
Environment = var.environment
}
}
resource "aws_cloudfront_origin_access_identity" "main" {
comment = "${var.project_name} OAI - ${var.environment}"
}
Part 5: Monitoring & Logging
infrastructure/modules/monitoring/main.tf:
# CloudWatch Log Groups
resource "aws_cloudwatch_log_group" "lambda_auth" {
name = "/aws/lambda/${var.project_name}-auth-${var.environment}"
retention_in_days = var.log_retention_days
tags = {
Name = "lambda-auth-logs"
Environment = var.environment
}
}
resource "aws_cloudwatch_log_group" "lambda_users" {
name = "/aws/lambda/${var.project_name}-users-${var.environment}"
retention_in_days = var.log_retention_days
}
resource "aws_cloudwatch_log_group" "lambda_files" {
name = "/aws/lambda/${var.project_name}-files-${var.environment}"
retention_in_days = var.log_retention_days
}
resource "aws_cloudwatch_log_group" "api_gateway" {
name = "/aws/apigateway/${var.project_name}-api-${var.environment}"
retention_in_days = var.log_retention_days
}
# CloudWatch Alarms
# API Gateway 5XX errors
resource "aws_cloudwatch_metric_alarm" "api_5xx_errors" {
alarm_name = "${var.project_name}-api-5xx-errors-${var.environment}"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "5XXError"
namespace = "AWS/ApiGateway"
period = 300
statistic = "Sum"
threshold = 10
alarm_description = "This metric monitors API Gateway 5XX errors"
alarm_actions = [aws_sns_topic.alerts.arn]
dimensions = {
ApiName = var.api_gateway_name
}
}
# Lambda concurrent executions
resource "aws_cloudwatch_metric_alarm" "lambda_concurrent_executions" {
alarm_name = "${var.project_name}-lambda-concurrent-${var.environment}"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 1
metric_name = "ConcurrentExecutions"
namespace = "AWS/Lambda"
period = 60
statistic = "Maximum"
threshold = 800 # 80% of default limit
alarm_description = "Lambda concurrent execution nearing limit"
alarm_actions = [aws_sns_topic.alerts.arn]
}
# DynamoDB read/write capacity alarms
resource "aws_cloudwatch_metric_alarm" "dynamodb_read_throttles" {
alarm_name = "${var.project_name}-dynamodb-read-throttles-${var.environment}"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "UserErrors"
namespace = "AWS/DynamoDB"
period = 300
statistic = "Sum"
threshold = 5
alarm_description = "DynamoDB read throttling detected"
alarm_actions = [aws_sns_topic.alerts.arn]
dimensions = {
TableName = var.users_table_name
}
}
# Cognito user pool sign-in failures
resource "aws_cloudwatch_metric_alarm" "cognito_failed_signins" {
alarm_name = "${var.project_name}-cognito-failed-signins-${var.environment}"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 1
metric_name = "UserAuthenticationFailure"
namespace = "AWS/Cognito"
period = 300
statistic = "Sum"
threshold = 50
alarm_description = "High number of failed sign-in attempts"
alarm_actions = [aws_sns_topic.alerts.arn]
dimensions = {
UserPool = var.cognito_user_pool_id
}
}
# SNS topic for alerts
resource "aws_sns_topic" "alerts" {
name = "${var.project_name}-alerts-${var.environment}"
tags = {
Name = "${var.project_name}-alerts-${var.environment}"
Environment = var.environment
}
}
resource "aws_sns_topic_subscription" "alerts_email" {
topic_arn = aws_sns_topic.alerts.arn
protocol = "email"
endpoint = var.alert_email
}
# CloudWatch Dashboard
resource "aws_cloudwatch_dashboard" "main" {
dashboard_name = "${var.project_name}-dashboard-${var.environment}"
dashboard_body = jsonencode({
widgets = [
{
type = "metric"
properties = {
metrics = [
["AWS/ApiGateway", "Count", { stat = "Sum", label = "API Requests" }],
[".", "5XXError", { stat = "Sum", label = "5XX Errors" }],
[".", "4XXError", { stat = "Sum", label = "4XX Errors" }]
]
period = 300
stat = "Sum"
region = var.aws_region
title = "API Gateway Metrics"
}
},
{
type = "metric"
properties = {
metrics = [
["AWS/Lambda", "Invocations", { stat = "Sum", label = "Invocations" }],
[".", "Errors", { stat = "Sum", label = "Errors" }],
[".", "Throttles", { stat = "Sum", label = "Throttles" }]
]
period = 300
stat = "Sum"
region = var.aws_region
title = "Lambda Metrics"
}
},
{
type = "metric"
properties = {
metrics = [
["AWS/DynamoDB", "ConsumedReadCapacityUnits", { stat = "Sum" }],
[".", "ConsumedWriteCapacityUnits", { stat = "Sum" }]
]
period = 300
stat = "Sum"
region = var.aws_region
title = "DynamoDB Capacity"
}
}
]
})
}
Part 6: Frontend Implementation
6.1 Authentication Context
frontend/src/contexts/AuthContext.tsx:
import React, { createContext, useContext, useState, useEffect } from 'react';
import { CognitoUser, AuthenticationDetails } from 'amazon-cognito-identity-js';
import userPool from '../lib/cognito';
interface AuthContextType {
user: CognitoUser | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
signup: (email: string, password: string, name: string) => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<CognitoUser | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const currentUser = userPool.getCurrentUser();
if (currentUser) {
currentUser.getSession((err: any, session: any) => {
if (err) {
setLoading(false);
return;
}
if (session.isValid()) {
setUser(currentUser);
}
setLoading(false);
});
} else {
setLoading(false);
}
}, []);
const login = async (email: string, password: string) => {
const authDetails = new AuthenticationDetails({
Username: email,
Password: password,
});
const cognitoUser = new CognitoUser({
Username: email,
Pool: userPool,
});
return new Promise((resolve, reject) => {
cognitoUser.authenticateUser(authDetails, {
onSuccess: (session) => {
setUser(cognitoUser);
resolve();
},
onFailure: (err) => {
reject(err);
},
});
});
};
const logout = () => {
const currentUser = userPool.getCurrentUser();
if (currentUser) {
currentUser.signOut();
}
setUser(null);
};
const signup = async (email: string, password: string, name: string) => {
return new Promise((resolve, reject) => {
userPool.signUp(
email,
password,
[{ Name: 'name', Value: name }],
[],
(err, result) => {
if (err) {
reject(err);
return;
}
resolve();
}
);
});
};
return (
<AuthContext.Provider value={{ user, loading, login, logout, signup }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
};
6.2 File Upload Component
frontend/src/components/FileUpload.tsx:
import React, { useState } from 'react';
import { apiClient } from '../lib/api';
export const FileUpload: React.FC = () => {
const [file, setFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
setFile(e.target.files[0]);
}
};
const handleUpload = async () => {
if (!file) return;
setUploading(true);
setProgress(0);
try {
// Get pre-signed URL
const { fileId, uploadUrl } = await apiClient.generateUploadUrl({
fileName: file.name,
fileType: file.type,
fileSize: file.size,
});
// Upload to S3
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentComplete = (e.loaded / e.total) * 100;
setProgress(percentComplete);
}
});
xhr.addEventListener('load', async () => {
if (xhr.status === 200) {
// Confirm upload completion
await apiClient.confirmUpload(fileId);
setProgress(100);
alert('File uploaded successfully!');
setFile(null);
} else {
throw new Error('Upload failed');
}
setUploading(false);
});
xhr.open('PUT', uploadUrl);
xhr.setRequestHeader('Content-Type', file.type);
xhr.send(file);
} catch (error) {
console.error('Upload error:', error);
alert('Upload failed. Please try again.');
setUploading(false);
}
};
return (
<div className="file-upload">
<input type="file" onChange={handleFileSelect} disabled={uploading} />
<button onClick={handleUpload} disabled={!file || uploading}>
{uploading ? `Uploading ${progress.toFixed(0)}%` : 'Upload'}
</button>
</div>
);
};
Part 7: Deployment
7.1 Deploy Infrastructure
cd infrastructure
# Initialize Terraform
terraform init
# Plan infrastructure changes
terraform plan -var-file=environments/prod.tfvars
# Apply infrastructure
terraform apply -var-file=environments/prod.tfvars
7.2 Deploy Lambda Functions
cd backend
# Install dependencies
npm install
# Build TypeScript
npm run build
# Package Lambda functions
./scripts/package-lambdas.sh
# Deploy to AWS (using SAM or direct upload)
aws lambda update-function-code \
--function-name my-app-auth-login-prod \
--zip-file fileb://dist/auth-login.zip
7.3 Deploy Frontend
cd frontend
# Build production bundle
npm run build
# Sync to S3
aws s3 sync out/ s3://my-app-website-prod/
# Invalidate CloudFront cache
aws cloudfront create-invalidation \
--distribution-id ABCDEF123456 \
--paths "/*"
Part 8: Testing & Monitoring
8.1 Load Testing
Use tools like Artillery or k6 for load testing:
# artillery-config.yml
config:
target: "https://d123456.cloudfront.net"
phases:
- duration: 60
arrivalRate: 10
name: "Warm up"
- duration: 120
arrivalRate: 50
name: "Sustained load"
- duration: 60
arrivalRate: 100
name: "Spike"
scenarios:
- name: "User flow"
flow:
- post:
url: "/api/auth/login"
json:
email: "test@example.com"
password: "TestPassword123!"
- get:
url: "/api/users/me"
headers:
Authorization: "Bearer {{ authToken }}"
8.2 Monitoring Best Practices
- Set up CloudWatch dashboards for real-time metrics
- Configure SNS alerts for critical events
- Enable AWS X-Ray for distributed tracing
- Use CloudWatch Insights for log analysis
- Set up cost alerts to monitor spending
Conclusion
You now have a production-ready global web application with:
- ✅ Global distribution via CloudFront
- ✅ Secure authentication with Cognito
- ✅ Scalable API with Lambda + API Gateway
- ✅ Managed database with DynamoDB
- ✅ File uploads to S3
- ✅ Comprehensive monitoring and alerting
- ✅ Infrastructure as Code with Terraform
Next Steps
- Add CI/CD pipeline using GitHub Actions or AWS CodePipeline
- Implement automated testing (unit, integration, e2e)
- Add custom domain with Route 53 and ACM certificate
- Implement rate limiting and WAF rules
- Add backup and disaster recovery procedures
- Set up multi-region failover for high availability
Cost Estimation
Option 1: Strategic 9-Region Deployment
For moderate traffic (~1M requests/month, 10k active users):
Per Region (~$50-65/month × 9 regions): - API Gateway: $3.50 (REST API) - Lambda: $5-10 (1M invocations) - DynamoDB replica: $25-40 (on-demand pricing) - CloudWatch: $2-5
Global Services: - CloudFront: $100-150/month (global CDN with compression) - S3 storage: $20-30/month (cross-region replication) - Cognito: $0 (under 50k MAUs) - Route53: $1/month (hosted zone + health checks)
Total: ~$555-685/month
Breakdown: - 9 regions × $60/region: $540 - Global services: $121-145
Option 2: Maximum Coverage - 34 Regions
For high-traffic global SaaS (~10M requests/month, 50k active users):
Per Region (~$50-65/month × 34 regions): - API Gateway: $3.50 - Lambda: $5-10 - CloudWatch: $2-5
Strategic DynamoDB Replicas (12-15 regions): - DynamoDB Global Tables: $300-500/month
Global Services: - CloudFront: $150-250/month - S3 storage: $50-100/month - Cognito: $50-100/month (50k MAUs) - Global Accelerator: $50/month (for Cognito) - Route53: $5/month
Total: ~$1,950-2,500/month
Breakdown: - 34 API regions × $60: $2,040 - 12 DynamoDB replicas × $35: $420 - Global services: $305-505 - Minus overlap: ~$1,950-2,500
Cost Optimization Tips
- Use CloudFront caching - Reduce API Gateway requests by 70-90%
- DynamoDB on-demand pricing - Only pay for what you use, no capacity planning
- Lambda SnapStart - Reduce cold starts and execution time
- S3 Intelligent-Tiering - Automatically move infrequent data to cheaper storage
- CloudWatch Logs retention - Set to 7-14 days instead of indefinite
- Delete old DynamoDB table versions - Use lifecycle rules
- API Gateway caching - Cache frequent GET requests for 300-3600 seconds
When Each Option Makes Sense
Choose 9-Region Deployment if: - Bootstrap/early-stage SaaS (<10k users) - B2B SaaS with known customer regions - Cost-conscious but need global presence - Budget: $500-1,000/month
Choose 34-Region Deployment if: - Enterprise SaaS with global customers - Financial services requiring <30ms latency - Gaming/real-time applications - Budget: $2,000-5,000/month
Real-World Example: Global SaaS Scaling Journey
Month 1-6 (MVP, 1k users): - 3 regions: us-east-2, eu-west-1, ap-southeast-1 - Stack: DynamoDB, API Gateway, Lambda, Cognito (1 pool) - Cost: ~$180-250/month - Latency: <100ms for 80% of users
Month 7-12 (Growth, 10k users): - 9 strategic regions: Add us-west-2, sa-east-1, ap-south-1, ap-northeast-1, eu-central-1, af-south-1 - Stack: Add Cognito multi-region (3 pools), DynamoDB Global Tables - Cost: ~$555-685/month - Latency: <100ms for 95% of users
Year 2+ (Scale, 100k+ users): - 34 regions: Deploy API to all AWS commercial regions - Stack: Optimize with caching, CloudFront edge functions - Cost: ~$1,950-2,500/month - Revenue: $50k-500k/month (typical B2B SaaS at this scale) - Latency: <50ms for 99% of users
Key Insight: Cost per user drops as you scale - MVP: $0.25/user/month - Growth: $0.06/user/month - Scale: $0.02/user/month
Profitability Math: - If ARPU (Average Revenue Per User) = $10/month - Infrastructure = $0.02/user 99.8% gross margin on infrastructure
Resources
- AWS Well-Architected Framework
- Terraform AWS Provider Docs
- AWS Lambda Best Practices
- Cognito Security Best Practices
GitHub Repository: github.com/yourname/global-web-app
This guide is a living document. Star the repo and contribute improvements!