Guide · AWS architecture

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.

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)

Active-Passive:

Primary:   Cognito pool in us-east-2 (active)
Backup:    Cognito pool in eu-west-1 (standby)

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.

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:

  1. 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 ✅
  2. 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 ✅
  3. No Brute Force

    Attacker tries 10,000 common passwords
    Password-based:  Eventually guesses weak password ❌
    Passwordless:    No password field to attack attack fails ✅
  4. 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:

  1. Magic Links (Email-based)

    User: Enter email Check inbox Click link Authenticated
    Pros: Familiar UX, works everywhere
    Cons: Email delivery delays (3-10 sec)
  2. 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 attacks
  3. WebAuthn/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:

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:

Result: 50-70% latency reduction for authentication calls.

Prerequisites

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

  1. Create custom userId - Generate time-sortable, globally unique ID
  2. Store in DynamoDB - Your single source of truth
  3. Link to Cognito - Store userId as custom attribute in Cognito
  4. 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"

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)

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)

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

  1. Set up CloudWatch dashboards for real-time metrics
  2. Configure SNS alerts for critical events
  3. Enable AWS X-Ray for distributed tracing
  4. Use CloudWatch Insights for log analysis
  5. Set up cost alerts to monitor spending

Conclusion

You now have a production-ready global web application with:

Next Steps

  1. Add CI/CD pipeline using GitHub Actions or AWS CodePipeline
  2. Implement automated testing (unit, integration, e2e)
  3. Add custom domain with Route 53 and ACM certificate
  4. Implement rate limiting and WAF rules
  5. Add backup and disaster recovery procedures
  6. 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

  1. Use CloudFront caching - Reduce API Gateway requests by 70-90%
  2. DynamoDB on-demand pricing - Only pay for what you use, no capacity planning
  3. Lambda SnapStart - Reduce cold starts and execution time
  4. S3 Intelligent-Tiering - Automatically move infrequent data to cheaper storage
  5. CloudWatch Logs retention - Set to 7-14 days instead of indefinite
  6. Delete old DynamoDB table versions - Use lifecycle rules
  7. 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


GitHub Repository: github.com/yourname/global-web-app

This guide is a living document. Star the repo and contribute improvements!

More writing  ·  BuriCloud home