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
- You are standing up B2C or B2B sign-in for a web/mobile app or an API behind an Application Load Balancer or API Gateway authorizer, and you want OIDC tokens without operating Keycloak/Auth0.
- You run multiple environments or multiple apps and need every user pool to share the same hardened password/MFA/advanced-security baseline.
- You need a hosted login UI (the
*.auth.<region>.amazoncognito.comdomain or a custom domain) plus an app client with specific OAuth flows, scopes, and callback URLs. - You want federation (Google, SAML, OIDC) wired in later — the pool and client created here are the anchor those identity providers attach to.
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 config — live/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 config — live/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
- Lead with strong defaults and enforce MFA in production. Keep the 12+ character password floor, set
mfa_configuration = "ON"for customer-facing prod pools, and leaveadvanced_security_mode = "ENFORCED"so Cognito blocks compromised-credential and impossible-travel sign-ins.AUDITis fine only while you are still tuning risk responses. - Never put a client secret in a public client. Browser SPAs and mobile apps must use
generate_client_secret = falsewith the Authorization Code + PKCE flow; reservegenerate_client_secret = truefor confidential, server-side clients and treat theclient_secretoutput as sensitive (it already is) — never log it or surface it in plan output. - Keep token lifetimes short and rely on refresh tokens. A 30–60 minute access/ID token with a bounded refresh-token window limits the blast radius of a stolen token;
enable_token_revocation(on by default here) lets you actively invalidate sessions on logout or compromise. - Move off the default email sender before launch. Cognito’s built-in email is rate-limited (roughly 50 messages/day) and unbranded — wire
ses_source_arnto a verified SES identity so sign-up, MFA, and recovery emails actually deliver at scale and pass DMARC. - Protect the directory and avoid user enumeration. Leave
deletion_protection = "ACTIVE"so a strayterraform destroycan’t wipe your user base, keepprevent_user_existence_errors = "ENABLED"(set here) so attackers can’t probe which emails are registered, and usecase_sensitive = falseto prevent duplicate-identity confusion. - Name and tag for multi-tenant clarity. Encode environment and app into
name(e.g.acme-storefront-prod) since the hosted-UI prefix must be globally unique, and tag every pool withEnvironment/Teamso cost and ownership are attributable across accounts.