Cognito Email via SES
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)
| Resource | TF address | File |
|---|---|---|
phenom-prod pool email_configuration | aws_cognito_user_pool.main.email_configuration | phenom-infra/environments/production/cognito.tf |
phenom-staging pool email_configuration | aws_cognito_user_pool.main.email_configuration | phenom-infra/environments/development/main.tf |
phenom-dev-local pool email_configuration | aws_cognito_user_pool.local.email_configuration | phenom-infra/environments/development/main.tf |
| SES identity (data source, all envs) | data.aws_ses_email_identity.noreply | declared 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_noreply | phenom-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:
- Resend the invite with
admin-create-user --message-action RESEND— sends a fresh temporary password through the invite-template path. - Set the password directly with
admin-set-user-password ... --permanent, which flips the user toCONFIRMEDwithout an email round-trip.
After either, ForgotPassword works normally on subsequent attempts.
Related
- phenom-prod cognito.tf
- phenom-staging + phenom-dev-local in development/main.tf
Phenom.mdOperational Note — “Cognito pools are environment-scoped — no cross-pool user accounts”Phenom.mdOperational Note — “All AWS management MUST go through Terraform in phenom-infra”
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.