IaC AWS

Terraform Module: AWS Cognito User Pool — Hardened, Standards-Ready Identity in One Block

Quick take — A reusable hashicorp/aws ~> 5.0 Terraform module for AWS Cognito User Pools: tunable password policy, MFA, advanced security mode, an app client with OAuth flows, and a hosted-UI domain — wired with validations and outputs. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "aws" {
  region = "us-east-1"
}

module "cognito" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-cognito?ref=v1.0.0"

  name = "..."  # Name of the user pool; base for client/domain naming.
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

An AWS Cognito User Pool is a managed user directory that handles sign-up, sign-in, token issuance (OIDC/OAuth 2.0), password policies, MFA, and account-recovery flows for your applications. It is the identity provider you reach for when you don’t want to run your own auth stack but still need OpenID Connect tokens, federated social/SAML logins, and a hosted login UI.

The raw aws_cognito_user_pool resource is deceptively large: it has nested blocks for password policy, MFA configuration, schema attributes, account recovery, Lambda triggers, email/SMS delivery, and (separately) an app client and a hosted-UI domain. Getting these consistent across dev, staging, and prod by hand is exactly where drift and weak defaults creep in — one environment ends up with MFA = OFF and an 8-character password minimum nobody intended.

This module wraps aws_cognito_user_pool together with the two resources you almost always need alongside it — an aws_cognito_user_pool_client (with OAuth 2.0 flows and token validity) and an aws_cognito_user_pool_domain (the hosted UI / OAuth endpoint) — behind a small, validated, var-driven interface. You declare the security posture once; every consumer gets a pool that defaults to strong passwords, optional-but-easy MFA, and advanced security enforcement.

When to use it

Reach for something else when you need machine-to-machine only auth with no user directory (a plain client-credentials setup may be lighter), or when your org has standardized on an external IdP and Cognito would just be a redundant hop.

Module structure

terraform-module-aws-cognito/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
# versions.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}
# main.tf

locals {
  # Cognito only allows SMS/software-token MFA when MFA is not OFF.
  mfa_enabled = var.mfa_configuration != "OFF"

  # Hosted-UI domains have different validity rules for the prefix vs a custom FQDN.
  # When using the Cognito prefix domain, AWS prepends nothing — the prefix must be globally unique.
  use_custom_domain = var.domain_certificate_arn != null
}

resource "aws_cognito_user_pool" "this" {
  name = var.name

  # ---- Sign-in / aliasing ----
  username_attributes      = var.username_attributes
  alias_attributes         = var.alias_attributes
  auto_verified_attributes = var.auto_verified_attributes

  username_configuration {
    case_sensitive = var.username_case_sensitive
  }

  # ---- Password policy ----
  password_policy {
    minimum_length                   = var.password_minimum_length
    require_lowercase                = var.password_require_lowercase
    require_uppercase                = var.password_require_uppercase
    require_numbers                  = var.password_require_numbers
    require_symbols                  = var.password_require_symbols
    temporary_password_validity_days = var.temporary_password_validity_days
  }

  # ---- MFA ----
  mfa_configuration = var.mfa_configuration

  dynamic "software_token_mfa_configuration" {
    for_each = local.mfa_enabled && var.enable_software_token_mfa ? [1] : []
    content {
      enabled = true
    }
  }

  # ---- Advanced security (threat protection) ----
  user_pool_add_ons {
    advanced_security_mode = var.advanced_security_mode
  }

  # ---- Account recovery ----
  account_recovery_setting {
    dynamic "recovery_mechanism" {
      for_each = var.account_recovery_mechanisms
      content {
        name     = recovery_mechanism.value.name
        priority = recovery_mechanism.value.priority
      }
    }
  }

  # ---- Self sign-up policy ----
  admin_create_user_config {
    allow_admin_create_user_only = var.allow_admin_create_user_only
  }

  # ---- Email delivery (defaults to Cognito's sandboxed sender unless SES is supplied) ----
  email_configuration {
    email_sending_account  = var.ses_source_arn == null ? "COGNITO_DEFAULT" : "DEVELOPER"
    source_arn             = var.ses_source_arn
    from_email_address     = var.ses_from_email_address
    reply_to_email_address = var.ses_reply_to_email_address
  }

  # ---- Deletion protection ----
  deletion_protection = var.deletion_protection

  tags = var.tags

  lifecycle {
    # The hosted-UI domain holds a dependency on the pool; avoid churn on schema-less changes.
    ignore_changes = [schema]
  }
}

resource "aws_cognito_user_pool_client" "this" {
  name         = "${var.name}-client"
  user_pool_id = aws_cognito_user_pool.this.id

  generate_secret = var.generate_client_secret

  # ---- Auth flows ----
  explicit_auth_flows = var.explicit_auth_flows

  # ---- OAuth 2.0 / hosted UI ----
  allowed_oauth_flows_user_pool_client = var.enable_oauth
  allowed_oauth_flows                  = var.enable_oauth ? var.allowed_oauth_flows : null
  allowed_oauth_scopes                 = var.enable_oauth ? var.allowed_oauth_scopes : null
  supported_identity_providers         = var.supported_identity_providers

  callback_urls = var.callback_urls
  logout_urls   = var.logout_urls

  # ---- Token validity ----
  access_token_validity  = var.access_token_validity_minutes
  id_token_validity      = var.id_token_validity_minutes
  refresh_token_validity = var.refresh_token_validity_days

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

  # ---- Anti-enumeration / token revocation ----
  prevent_user_existence_errors = "ENABLED"
  enable_token_revocation       = true

  depends_on = [aws_cognito_user_pool.this]
}

resource "aws_cognito_user_pool_domain" "this" {
  count = var.domain_prefix == null ? 0 : 1

  domain          = var.domain_prefix
  user_pool_id    = aws_cognito_user_pool.this.id
  certificate_arn = local.use_custom_domain ? var.domain_certificate_arn : null
}
# variables.tf

variable "name" {
  description = "Name of the Cognito User Pool. Used as the base for client and domain naming."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z0-9_+=,.@-]{1,128}$", var.name))
    error_message = "name must be 1-128 chars and may only contain letters, numbers, and _ + = , . @ -."
  }
}

# ---------------------------------------------------------------------------
# Sign-in behaviour
# ---------------------------------------------------------------------------

variable "username_attributes" {
  description = "Attributes users can sign in with (e.g. [\"email\"], [\"phone_number\"]). Mutually exclusive with alias_attributes."
  type        = list(string)
  default     = ["email"]

  validation {
    condition     = alltrue([for a in var.username_attributes : contains(["email", "phone_number"], a)])
    error_message = "username_attributes may only contain \"email\" and/or \"phone_number\"."
  }
}

variable "alias_attributes" {
  description = "Alternative sign-in aliases. Set this OR username_attributes, not both."
  type        = list(string)
  default     = null
}

variable "auto_verified_attributes" {
  description = "Attributes Cognito auto-verifies (sends a verification code for)."
  type        = list(string)
  default     = ["email"]

  validation {
    condition     = alltrue([for a in var.auto_verified_attributes : contains(["email", "phone_number"], a)])
    error_message = "auto_verified_attributes may only contain \"email\" and/or \"phone_number\"."
  }
}

variable "username_case_sensitive" {
  description = "Whether usernames are case sensitive. Recommended false to avoid duplicate-account confusion."
  type        = bool
  default     = false
}

# ---------------------------------------------------------------------------
# Password policy
# ---------------------------------------------------------------------------

variable "password_minimum_length" {
  description = "Minimum password length (Cognito allows 6-99)."
  type        = number
  default     = 12

  validation {
    condition     = var.password_minimum_length >= 6 && var.password_minimum_length <= 99
    error_message = "password_minimum_length must be between 6 and 99."
  }
}

variable "password_require_lowercase" {
  description = "Require at least one lowercase letter."
  type        = bool
  default     = true
}

variable "password_require_uppercase" {
  description = "Require at least one uppercase letter."
  type        = bool
  default     = true
}

variable "password_require_numbers" {
  description = "Require at least one number."
  type        = bool
  default     = true
}

variable "password_require_symbols" {
  description = "Require at least one symbol."
  type        = bool
  default     = true
}

variable "temporary_password_validity_days" {
  description = "Days an admin-created temporary password stays valid before expiring."
  type        = number
  default     = 7

  validation {
    condition     = var.temporary_password_validity_days >= 0 && var.temporary_password_validity_days <= 365
    error_message = "temporary_password_validity_days must be between 0 and 365."
  }
}

# ---------------------------------------------------------------------------
# MFA & threat protection
# ---------------------------------------------------------------------------

variable "mfa_configuration" {
  description = "MFA enforcement: OFF, ON (required), or OPTIONAL."
  type        = string
  default     = "OPTIONAL"

  validation {
    condition     = contains(["OFF", "ON", "OPTIONAL"], var.mfa_configuration)
    error_message = "mfa_configuration must be one of OFF, ON, or OPTIONAL."
  }
}

variable "enable_software_token_mfa" {
  description = "Enable TOTP (authenticator app) MFA. Only applied when mfa_configuration != OFF."
  type        = bool
  default     = true
}

variable "advanced_security_mode" {
  description = "Threat protection mode: OFF, AUDIT (log only), or ENFORCED (block risky sign-ins)."
  type        = string
  default     = "ENFORCED"

  validation {
    condition     = contains(["OFF", "AUDIT", "ENFORCED"], var.advanced_security_mode)
    error_message = "advanced_security_mode must be one of OFF, AUDIT, or ENFORCED."
  }
}

# ---------------------------------------------------------------------------
# Account recovery & sign-up
# ---------------------------------------------------------------------------

variable "account_recovery_mechanisms" {
  description = "Ordered recovery mechanisms. Lower priority number = tried first."
  type = list(object({
    name     = string
    priority = number
  }))
  default = [
    { name = "verified_email", priority = 1 }
  ]

  validation {
    condition = alltrue([
      for m in var.account_recovery_mechanisms :
      contains(["verified_email", "verified_phone_number", "admin_only"], m.name)
    ])
    error_message = "recovery mechanism name must be verified_email, verified_phone_number, or admin_only."
  }
}

variable "allow_admin_create_user_only" {
  description = "If true, disables public self sign-up; only admins may create users."
  type        = bool
  default     = false
}

# ---------------------------------------------------------------------------
# Email delivery (SES)
# ---------------------------------------------------------------------------

variable "ses_source_arn" {
  description = "ARN of a verified SES identity to send from. Null uses Cognito's default (rate-limited) sender."
  type        = string
  default     = null
}

variable "ses_from_email_address" {
  description = "From address shown to users (requires ses_source_arn)."
  type        = string
  default     = null
}

variable "ses_reply_to_email_address" {
  description = "Reply-to address for Cognito-sent emails."
  type        = string
  default     = null
}

# ---------------------------------------------------------------------------
# App client
# ---------------------------------------------------------------------------

variable "generate_client_secret" {
  description = "Generate a client secret. Use true for confidential (server-side) clients, false for SPAs/mobile."
  type        = bool
  default     = false
}

variable "explicit_auth_flows" {
  description = "Permitted auth flows for the app client."
  type        = list(string)
  default     = ["ALLOW_USER_SRP_AUTH", "ALLOW_REFRESH_TOKEN_AUTH"]

  validation {
    condition = alltrue([
      for f in var.explicit_auth_flows : contains([
        "ALLOW_ADMIN_USER_PASSWORD_AUTH",
        "ALLOW_CUSTOM_AUTH",
        "ALLOW_USER_PASSWORD_AUTH",
        "ALLOW_USER_SRP_AUTH",
        "ALLOW_REFRESH_TOKEN_AUTH"
      ], f)
    ])
    error_message = "explicit_auth_flows contains an unsupported flow value."
  }
}

variable "enable_oauth" {
  description = "Enable OAuth 2.0 / hosted-UI flows on the app client."
  type        = bool
  default     = true
}

variable "allowed_oauth_flows" {
  description = "OAuth 2.0 grant flows (code, implicit, client_credentials)."
  type        = list(string)
  default     = ["code"]

  validation {
    condition     = alltrue([for f in var.allowed_oauth_flows : contains(["code", "implicit", "client_credentials"], f)])
    error_message = "allowed_oauth_flows may only contain code, implicit, or client_credentials."
  }
}

variable "allowed_oauth_scopes" {
  description = "OAuth scopes granted by the hosted UI."
  type        = list(string)
  default     = ["openid", "email", "profile"]
}

variable "supported_identity_providers" {
  description = "Identity providers usable by this client (e.g. COGNITO, Google, a SAML provider name)."
  type        = list(string)
  default     = ["COGNITO"]
}

variable "callback_urls" {
  description = "Allowed OAuth callback (redirect) URLs."
  type        = list(string)
  default     = []
}

variable "logout_urls" {
  description = "Allowed sign-out redirect URLs."
  type        = list(string)
  default     = []
}

variable "access_token_validity_minutes" {
  description = "Access token lifetime in minutes (5 min - 24 h)."
  type        = number
  default     = 60

  validation {
    condition     = var.access_token_validity_minutes >= 5 && var.access_token_validity_minutes <= 1440
    error_message = "access_token_validity_minutes must be between 5 and 1440 (24h)."
  }
}

variable "id_token_validity_minutes" {
  description = "ID token lifetime in minutes (5 min - 24 h)."
  type        = number
  default     = 60

  validation {
    condition     = var.id_token_validity_minutes >= 5 && var.id_token_validity_minutes <= 1440
    error_message = "id_token_validity_minutes must be between 5 and 1440 (24h)."
  }
}

variable "refresh_token_validity_days" {
  description = "Refresh token lifetime in days (1 - 3650)."
  type        = number
  default     = 30

  validation {
    condition     = var.refresh_token_validity_days >= 1 && var.refresh_token_validity_days <= 3650
    error_message = "refresh_token_validity_days must be between 1 and 3650."
  }
}

# ---------------------------------------------------------------------------
# Hosted-UI domain
# ---------------------------------------------------------------------------

variable "domain_prefix" {
  description = "Hosted-UI domain prefix (e.g. \"acme-prod\") or full custom FQDN. Null skips domain creation."
  type        = string
  default     = null
}

variable "domain_certificate_arn" {
  description = "ACM certificate ARN (us-east-1) for a custom domain. Null = use the Cognito prefix domain."
  type        = string
  default     = null
}

# ---------------------------------------------------------------------------
# Lifecycle & tagging
# ---------------------------------------------------------------------------

variable "deletion_protection" {
  description = "Deletion protection: ACTIVE or INACTIVE."
  type        = string
  default     = "ACTIVE"

  validation {
    condition     = contains(["ACTIVE", "INACTIVE"], var.deletion_protection)
    error_message = "deletion_protection must be ACTIVE or INACTIVE."
  }
}

variable "tags" {
  description = "Tags applied to the user pool."
  type        = map(string)
  default     = {}
}
# outputs.tf

output "user_pool_id" {
  description = "The ID of the Cognito User Pool."
  value       = aws_cognito_user_pool.this.id
}

output "user_pool_arn" {
  description = "The ARN of the Cognito User Pool (use this in IAM/ALB authorizer policies)."
  value       = aws_cognito_user_pool.this.arn
}

output "user_pool_name" {
  description = "The name of the Cognito User Pool."
  value       = aws_cognito_user_pool.this.name
}

output "user_pool_endpoint" {
  description = "The OIDC issuer endpoint (without scheme) used to discover JWKS and validate tokens."
  value       = aws_cognito_user_pool.this.endpoint
}

output "client_id" {
  description = "The app client ID."
  value       = aws_cognito_user_pool_client.this.id
}

output "client_secret" {
  description = "The app client secret (only set when generate_client_secret = true)."
  value       = aws_cognito_user_pool_client.this.client_secret
  sensitive   = true
}

output "hosted_ui_domain" {
  description = "The configured hosted-UI domain (prefix or custom FQDN), or null if none."
  value       = try(aws_cognito_user_pool_domain.this[0].domain, null)
}

output "cloudfront_distribution_arn" {
  description = "CloudFront distribution backing the hosted-UI domain (for custom domain DNS aliasing)."
  value       = try(aws_cognito_user_pool_domain.this[0].cloudfront_distribution_arn, null)
}

How to use it

module "cognito_user_pool" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-cognito?ref=v1.0.0"

  name = "acme-storefront-prod"

  # Sign in with email; verify email automatically.
  username_attributes      = ["email"]
  auto_verified_attributes = ["email"]

  # Hardened password baseline + required MFA + enforced threat protection.
  password_minimum_length = 14
  mfa_configuration       = "ON"
  advanced_security_mode  = "ENFORCED"

  # Public SPA client (no secret) using the Authorization Code flow with PKCE.
  generate_client_secret = false
  explicit_auth_flows    = ["ALLOW_USER_SRP_AUTH", "ALLOW_REFRESH_TOKEN_AUTH"]
  enable_oauth           = true
  allowed_oauth_flows    = ["code"]
  allowed_oauth_scopes   = ["openid", "email", "profile"]

  callback_urls = ["https://app.acme.com/auth/callback"]
  logout_urls   = ["https://app.acme.com/"]

  access_token_validity_minutes = 30
  refresh_token_validity_days   = 14

  # Hosted login UI at https://acme-storefront-prod.auth.<region>.amazoncognito.com
  domain_prefix = "acme-storefront-prod"

  # Send branded emails through verified SES instead of the sandboxed default.
  ses_source_arn         = aws_ses_email_identity.noreply.arn
  ses_from_email_address = "no-reply@acme.com"

  deletion_protection = "ACTIVE"

  tags = {
    Environment = "prod"
    Team        = "identity"
  }
}

# Downstream: protect an ALB listener using the pool + client this module created.
resource "aws_lb_listener_rule" "authenticated" {
  listener_arn = aws_lb_listener.https.arn
  priority     = 10

  action {
    type = "authenticate-cognito"

    authenticate_cognito {
      user_pool_arn       = module.cognito_user_pool.user_pool_arn
      user_pool_client_id = module.cognito_user_pool.client_id
      user_pool_domain    = module.cognito_user_pool.hosted_ui_domain
    }
  }

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.app.arn
  }

  condition {
    path_pattern {
      values = ["/app/*"]
    }
  }
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "s3"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...s3 state bucket/container + key per path...
  }
}

2. Module configlive/prod/cognito/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-cognito?ref=v1.0.0"
}

inputs = {
  name = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/cognito && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
name string yes Name of the user pool; base for client/domain naming.
username_attributes list(string) ["email"] no Sign-in attributes (email, phone_number).
alias_attributes list(string) null no Alternative sign-in aliases (mutually exclusive with username_attributes).
auto_verified_attributes list(string) ["email"] no Attributes Cognito auto-verifies.
username_case_sensitive bool false no Whether usernames are case sensitive.
password_minimum_length number 12 no Minimum password length (6–99).
password_require_lowercase bool true no Require a lowercase letter.
password_require_uppercase bool true no Require an uppercase letter.
password_require_numbers bool true no Require a number.
password_require_symbols bool true no Require a symbol.
temporary_password_validity_days number 7 no Admin temp-password validity in days (0–365).
mfa_configuration string "OPTIONAL" no MFA enforcement: OFF, ON, OPTIONAL.
enable_software_token_mfa bool true no Enable TOTP MFA (when MFA not OFF).
advanced_security_mode string "ENFORCED" no Threat protection: OFF, AUDIT, ENFORCED.
account_recovery_mechanisms list(object) [{verified_email,1}] no Ordered recovery mechanisms.
allow_admin_create_user_only bool false no Disable public self sign-up.
ses_source_arn string null no Verified SES identity ARN; null uses Cognito default sender.
ses_from_email_address string null no From address (requires ses_source_arn).
ses_reply_to_email_address string null no Reply-to address for Cognito emails.
generate_client_secret bool false no Generate a client secret (true = confidential client).
explicit_auth_flows list(string) ["ALLOW_USER_SRP_AUTH","ALLOW_REFRESH_TOKEN_AUTH"] no Permitted auth flows.
enable_oauth bool true no Enable OAuth 2.0 / hosted-UI flows.
allowed_oauth_flows list(string) ["code"] no OAuth grant flows.
allowed_oauth_scopes list(string) ["openid","email","profile"] no OAuth scopes.
supported_identity_providers list(string) ["COGNITO"] no Identity providers for the client.
callback_urls list(string) [] no Allowed OAuth callback URLs.
logout_urls list(string) [] no Allowed sign-out redirect URLs.
access_token_validity_minutes number 60 no Access token lifetime in minutes (5–1440).
id_token_validity_minutes number 60 no ID token lifetime in minutes (5–1440).
refresh_token_validity_days number 30 no Refresh token lifetime in days (1–3650).
domain_prefix string null no Hosted-UI domain prefix or custom FQDN; null skips domain.
domain_certificate_arn string null no ACM cert ARN (us-east-1) for a custom domain.
deletion_protection string "ACTIVE" no ACTIVE or INACTIVE.
tags map(string) {} no Tags applied to the user pool.

Outputs

Name Description
user_pool_id The ID of the Cognito User Pool.
user_pool_arn The ARN of the user pool (IAM/ALB authorizer references).
user_pool_name The name of the user pool.
user_pool_endpoint OIDC issuer endpoint (without scheme) for JWKS/token validation.
client_id The app client ID.
client_secret The app client secret (sensitive; set only when generate_client_secret = true).
hosted_ui_domain Configured hosted-UI domain, or null if none.
cloudfront_distribution_arn CloudFront distribution backing a custom hosted-UI domain.

Enterprise scenario

A retail platform team migrating off a self-managed Keycloak cluster uses this module to provision one user pool per environment (dev, staging, prod) from a single shared configuration. Production locks down with mfa_configuration = "ON", advanced_security_mode = "ENFORCED", a 14-character password floor, and branded transactional email via a verified SES identity, while non-prod relaxes MFA to OPTIONAL to keep QA fast. The user_pool_arn and client_id outputs feed straight into ALB authenticate-cognito listener rules, so the storefront’s /account/* routes are gated by Cognito without a single line of bespoke auth code in the application.

Best practices

TerraformAWSCognito User PoolModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading