Cognito Email via SES

All three Phenom Cognito user pools (phenom-prod, phenom-staging, phenom-dev-local) deliver password-reset, invite, and verification emails through SES using the verified noreply@thephenom.app identity. This page documents the wiring + the two-piece TF setup so the next pool created gets the same treatment.

The two-piece gotcha. Flipping a Cognito user pool to EmailSendingAccount = DEVELOPER is NOT enough to actually deliver email — you ALSO need an SES identity policy authorizing cognito-idp.amazonaws.com to call ses:SendEmail / SendRawEmail on the identity. Without that policy, Cognito returns success at the API but SES silently denies. The AWS Console wizard adds the policy automatically; Terraform setups commonly miss it.

Why SES instead of the Cognito default

Cognito’s built-in mailer (EmailSendingAccount = COGNITO_DEFAULT) is:

  • Rate-limited to 50 emails / 24h per pool. Hard AWS limit, no override.
  • Routed from a generic AWS sender that mail providers (especially Yahoo, Outlook) often flag as spam or junk.
  • Not customizable — you cannot set the From address, change the template, or align with the domain DKIM / SPF / DMARC posture.

For anything beyond toy testing, SES (DEVELOPER mode) is the only viable path. Our SES account is out of sandbox (production access enabled, 50,000 emails/day quota), and noreply@thephenom.app is DKIM-signed against the same thephenom.app domain that publishes SPF (include:amazonses.com) and DMARC (p=quarantine; pct=10).

Topology

graph LR
  User(User)
  Pool(Cognito User Pool<br/>EmailSendingAccount = DEVELOPER)
  Policy(SES identity policy<br/>AllowCognitoUserPoolsToSend)
  Identity(SES identity<br/>noreply@thephenom.app)
  SES(SES production sender)
  Inbox(Recipient inbox)

  User -->|ForgotPassword / AdminCreateUser RESEND| Pool
  Pool -->|SendEmail with SourceArn = identity ARN| Identity
  Policy -->|Resource policy: Allow cognito-idp service principal| Identity
  Identity -->|DKIM-signed, SPF-aligned| SES
  SES -->|Authenticated email| Inbox

TF layout (where each piece lives)

ResourceTF addressFile
phenom-prod pool email_configurationaws_cognito_user_pool.main.email_configurationphenom-infra/environments/production/cognito.tf
phenom-staging pool email_configurationaws_cognito_user_pool.main.email_configurationphenom-infra/environments/development/main.tf
phenom-dev-local pool email_configurationaws_cognito_user_pool.local.email_configurationphenom-infra/environments/development/main.tf
SES identity (data source, all envs)data.aws_ses_email_identity.noreplydeclared in BOTH production/cognito.tf AND development/main.tf (data sources, no state conflict)
SES identity policy (single declaration, account-wide)aws_ses_identity_policy.cognito_noreplyphenom-infra/environments/production/cognito.tf only — DO NOT redeclare elsewhere or the two TF states will fight

Adding a new Cognito pool

For any new pool in this account, two things to copy:

resource "aws_cognito_user_pool" "your_pool" {
  # ... your pool config ...

  email_configuration {
    email_sending_account  = "DEVELOPER"
    source_arn             = data.aws_ses_email_identity.noreply.arn
    from_email_address     = "Phenom <noreply@thephenom.app>"
    reply_to_email_address = "noreply@thephenom.app"
  }
}

data "aws_ses_email_identity" "noreply" {
  email = "noreply@thephenom.app"
}

The SES identity policy is already in place (environments/production/cognito.tf) and scoped to all cognito-idp.amazonaws.com calls from account 657033058608, so the new pool inherits authorization automatically.

Display name quirk: avoid parentheses in from_email_address. AWS auto-quotes RFC-5322 special chars when storing, but Terraform doesn’t pre-quote — so a name like Phenom (dev) <noreply@thephenom.app> causes a perpetual diff (Phenom (dev) <...> in TF, "Phenom (dev)" <...> in AWS). Stick with Phenom <noreply@thephenom.app> or pre-quote in the TF string literal.

Verifying it works

# Trigger an email (a password reset is the cleanest probe for a CONFIRMED user;
# for FORCE_CHANGE_PASSWORD users, use AdminCreateUser --message-action RESEND
# instead — ForgotPassword is rejected on that status).
aws cognito-idp admin-create-user \
  --user-pool-id us-east-1_knEL7cqS3 \
  --username <email> --message-action RESEND \
  --region us-east-1

# Real-time delivery confirm via CloudWatch (SentLast24Hours from
# sesv2 get-account has a 10-30 min lag and shouldn't be trusted for
# quick troubleshooting):
aws cloudwatch get-metric-statistics --region us-east-1 \
  --namespace AWS/SES --metric-name Send \
  --start-time "$(date -u -v-15M +%FT%TZ)" --end-time "$(date -u +%FT%TZ)" \
  --period 60 --statistics Sum

# Sanity-check the SES identity policy is still attached:
aws ses list-identity-policies --identity noreply@thephenom.app --region us-east-1
# Expected: { "PolicyNames": ["AllowCognitoUserPoolsToSend"] }

If the Send metric increments but the recipient doesn’t see the email, it’s a recipient-side classification problem (Yahoo + Outlook are aggressive on first-time senders). Check the recipient’s Spam / Bulk / Promotions folders and the mail-from-domain alignment (the noreply@thephenom.app identity has DKIM SUCCESS and the domain publishes SPF + DMARC, so authentication itself is clean).

User-state caveat — Cognito won’t email a FORCE_CHANGE_PASSWORD account

The FORCE_CHANGE_PASSWORD status (an admin-created user who hasn’t completed the initial-invite flow) blocks both ForgotPassword and AdminResetUserPassword — Cognito just refuses with NotAuthorizedException: User password cannot be reset in the current state. Two ways forward:

  1. Resend the invite with admin-create-user --message-action RESEND — sends a fresh temporary password through the invite-template path.
  2. Set the password directly with admin-set-user-password ... --permanent, which flips the user to CONFIRMED without an email round-trip.

After either, ForgotPassword works normally on subsequent attempts.