LAB45_AWS_User Authentication: Cognito User Pool + API Gateway JWT Authorizer

User Authentication: Cognito User Pool + API Gateway JWT Authorizer

☁ AWS 🔥 Advanced Cognito User Pool JWT Authorizer API Gateway v2 CORS OAuth 2.0

Scenario

Your React SPA calls a REST API. Currently the API has no authentication — anyone who finds the URL can call it. You need to add user authentication using Cognito User Pools: users sign up and sign in via Cognito, receiving a JWT access token, and the API Gateway validates the JWT on every request before forwarding it to Lambda.

Your Objectives
  • Create a Cognito User Pool with email as the username attribute, password policy (min 12 characters, uppercase, lowercase, numbers, symbols), and MFA optional.
  • Create a User Pool Client (no client secret — SPAs cannot keep secrets) with the ALLOW_USER_SRP_AUTH and ALLOW_REFRESH_TOKEN_AUTH flows.
  • Create an HTTP API (API Gateway v2) with a Lambda backend and a JWT authorizer pointing to the Cognito User Pool. All routes require the authorizer except POST /auth/signup.
  • Configure CORS on the HTTP API to allow your SPA's origin with GET, POST, OPTIONS methods and the Authorization header.
  • Output the User Pool ID, User Pool Client ID, and the HTTP API URL.

Additional Context

HTTP API (v2) vs REST API (v1): HTTP API is the newer and cheaper option — up to 71% cheaper per request. It natively supports JWT authorizers (including Cognito) and has simpler CORS configuration. REST API is older but offers features like request validation, API keys, and usage plans. For new serverless APIs with Cognito auth, HTTP API is the right choice.

SPA client secret rule: A public client (browser/mobile app) cannot securely store a client secret. In Terraform, set generate_secret = false on the Cognito User Pool Client. The SRP (Secure Remote Password) auth flow (ALLOW_USER_SRP_AUTH) is designed for public clients — it never transmits the password in plain text.

variables.tf

variable "aws_region"    { type = string; default = "us-east-1" }
variable "app_name"      { type = string; default = "my-app" }
variable "spa_origin"    { type = string; description = "SPA origin URL, e.g. https://app.example.com" }
variable "environment"   { type = string; default = "prod" }

main.tf

provider "aws" { region = var.aws_region }
locals { tags = { Environment = var.environment, ManagedBy = "terraform" } }

# Cognito User Pool.
resource "aws_cognito_user_pool" "main" {
  name = "${var.app_name}-user-pool-${var.environment}"

  # Use email as the primary sign-in identifier.
  username_attributes      = ["email"]
  auto_verified_attributes = ["email"]

  # Password policy.
  password_policy {
    minimum_length                   = 12
    require_uppercase                = true
    require_lowercase                = true
    require_numbers                  = true
    require_symbols                  = true
    temporary_password_validity_days = 7
  }

  # MFA — optional (user can enable it).
  mfa_configuration = "OPTIONAL"

  software_token_mfa_configuration { enabled = true }

  # Email verification.
  account_recovery_setting {
    recovery_mechanism { name = "verified_email"; priority = 1 }
  }

  tags = local.tags
}

# User Pool Client — no client secret (public SPA client).
resource "aws_cognito_user_pool_client" "spa" {
  name         = "${var.app_name}-spa-client"
  user_pool_id = aws_cognito_user_pool.main.id

  generate_secret = false  # Must be false for browser-based SPAs

  explicit_auth_flows = [
    "ALLOW_USER_SRP_AUTH",     # SRP is safe for public clients
    "ALLOW_REFRESH_TOKEN_AUTH" # allow token refresh
  ]

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

  access_token_validity  = 1   # 1 hour access tokens
  id_token_validity      = 1
  refresh_token_validity = 30  # 30-day refresh tokens

  # Prevent user existence errors from leaking (don't reveal if email is registered).
  prevent_user_existence_errors = "ENABLED"
}

# Lambda backend for the API.
data "aws_iam_policy_document" "lambda_assume" {
  statement { effect = "Allow"; actions = ["sts:AssumeRole"]
    principals { type = "Service"; identifiers = ["lambda.amazonaws.com"] } }
}
resource "aws_iam_role" "api_lambda" {
  name               = "${var.app_name}-api-lambda-role"
  assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
}
resource "aws_iam_role_policy_attachment" "api_lambda" {
  role       = aws_iam_role.api_lambda.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
resource "aws_lambda_function" "api" {
  function_name = "${var.app_name}-api-${var.environment}"
  runtime       = "python3.12"; handler = "api.lambda_handler"
  role          = aws_iam_role.api_lambda.arn; filename = "${path.module}/api.zip"
  tags          = local.tags
}

# HTTP API (v2) — cheaper and simpler than REST API for JWT auth.
resource "aws_apigatewayv2_api" "main" {
  name          = "${var.app_name}-api-${var.environment}"
  protocol_type = "HTTP"
  description   = "HTTP API with Cognito JWT auth"

  # CORS configuration — allow the SPA origin.
  cors_configuration {
    allow_origins  = [var.spa_origin]
    allow_methods  = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
    allow_headers  = ["Content-Type", "Authorization", "X-Amz-Date"]
    expose_headers = ["X-Request-Id"]
    max_age        = 300
  }

  tags = local.tags
}

# JWT Authorizer — validates Cognito-issued tokens on every protected route.
resource "aws_apigatewayv2_authorizer" "cognito" {
  api_id           = aws_apigatewayv2_api.main.id
  authorizer_type  = "JWT"
  identity_sources = ["$request.header.Authorization"]
  name             = "cognito-jwt-authorizer"

  jwt_configuration {
    # Audience must match the Cognito User Pool Client ID.
    audience = [aws_cognito_user_pool_client.spa.id]
    # Issuer is the Cognito User Pool OIDC endpoint.
    issuer   = "https://cognito-idp.${var.aws_region}.amazonaws.com/${aws_cognito_user_pool.main.id}"
  }
}

# Lambda integration.
resource "aws_apigatewayv2_integration" "lambda" {
  api_id             = aws_apigatewayv2_api.main.id
  integration_type   = "AWS_PROXY"
  integration_uri    = aws_lambda_function.api.invoke_arn
  payload_format_version = "2.0"
}

# Protected route — requires Cognito JWT.
resource "aws_apigatewayv2_route" "items" {
  api_id             = aws_apigatewayv2_api.main.id
  route_key          = "GET /items"
  target             = "integrations/${aws_apigatewayv2_integration.lambda.id}"
  authorization_type = "JWT"
  authorizer_id      = aws_apigatewayv2_authorizer.cognito.id
}

# Public route — no auth (for sign-up passthrough handled by Cognito itself).
resource "aws_apigatewayv2_route" "signup" {
  api_id             = aws_apigatewayv2_api.main.id
  route_key          = "POST /auth/signup"
  target             = "integrations/${aws_apigatewayv2_integration.lambda.id}"
  authorization_type = "NONE"
}

resource "aws_apigatewayv2_stage" "prod" {
  api_id      = aws_apigatewayv2_api.main.id
  name        = "$default"
  auto_deploy = true
  tags        = local.tags
}

resource "aws_lambda_permission" "apigw" {
  statement_id  = "AllowAPIGatewayInvoke"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.api.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_apigatewayv2_api.main.execution_arn}/*/*"
}

outputs.tf

output "user_pool_id"      { value = aws_cognito_user_pool.main.id }
output "client_id"         { value = aws_cognito_user_pool_client.spa.id }
output "api_url"           { value = aws_apigatewayv2_api.main.api_endpoint }
output "jwt_issuer"        {
  value = "https://cognito-idp.${var.aws_region}.amazonaws.com/${aws_cognito_user_pool.main.id}"
}

Workflow Commands

terraform init
terraform apply -var="spa_origin=https://app.example.com"

# Create a test user
aws cognito-idp admin-create-user \
  --user-pool-id $(terraform output -raw user_pool_id) \
  --username [email protected] \
  --temporary-password "TempPass123!" \
  --message-action SUPPRESS

# Authenticate and get tokens
aws cognito-idp initiate-auth \
  --auth-flow USER_PASSWORD_AUTH \
  --client-id $(terraform output -raw client_id) \
  --auth-parameters [email protected],PASSWORD=YourPass123!

# Call the protected API with the JWT
TOKEN=""
curl -H "Authorization: $TOKEN" $(terraform output -raw api_url)/items

# Call the public signup route (no auth required)
curl -X POST $(terraform output -raw api_url)/auth/signup \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","password":"NewPass123!"}'

terraform destroy -var="spa_origin=https://app.example.com"
✓ Expected Output After Apply
Apply complete! Resources: 12 added, 0 changed, 0 destroyed.

Outputs:

api_url      = "https://abcdefghij.execute-api.us-east-1.amazonaws.com"
client_id    = "3example8clientid7..."
jwt_issuer   = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_Abcdef123"
user_pool_id = "us-east-1_Abcdef123"