Cognito Authentication Flows
Categories:
Companion to Cognito Email via SES, which documents the email-delivery layer (SES wiring, identity policy, DKIM). This page documents the auth flows that ride on top: sign-in, password reset, signup lockdown, and operations.
Reflects live state as of 2026-05-19.
Pool inventory (live)
Three Cognito user pools, all in us-east-1, all in AWS account 657033058608:
| Name | Pool ID | Estimated users | Used by |
|---|---|---|---|
phenom-staging | us-east-1_n8gO6SbP6 | 13 | Mobile app production build (intentional staging-as-prod) |
phenom-prod | us-east-1_knEL7cqS3 | 4 | Reserved for future production cutover |
phenom-dev-local | us-east-1_AkG9mnbjA | 5 | Local-dev workstation (localhost:8080) |
App clients with callbacks:
| Pool | Client name | Client ID | Callback URLs |
|---|---|---|---|
| phenom-staging | phenom-dev-hasura-client | 6sjjnkaeagnqgkmbl1mr5rtfsr | http://localhost:3000/* |
| phenom-staging | phenom-dev-synapse-oidc | 73q703cql980nrvq554a6sta54 | https://chat-testing.thephenom.app/_synapse/client/oidc/callback |
| phenom-prod | phenom-prod-hasura-client | 8uun49ru7f3fdvmlc12vqig3a | https://www.thephenom.app/* |
| phenom-dev-local | phenom-dev-hasura-client-local | 2eq1vf0nvl5o3rha2vshm8j0mn | http://localhost:8080/* |
| phenom-dev-local | phenom-dev-nest-ops | 5u6atviker41lm8qknqua56sdc | https://nest-ops.thephenom.app/oauth2/idpresponse |
Hosted UI domains (Cognito-managed):
https://us-east-1n8go6sbp6.auth.us-east-1.amazoncognito.com(phenom-staging)https://phenom-prod-hasura-auth.auth.us-east-1.amazoncognito.com(phenom-prod)https://phenom-dev-hasura-auth.auth.us-east-1.amazoncognito.com(phenom-dev-local)
Self-signup disabled (issue #72)
All three pools enforce admin-only user creation. Live state (verified 2026-05-19):
admin_create_user_config {
allow_admin_create_user_only = true
}
SignUpAPI returnsNotAuthorizedException: SignUp is not permitted for this user pool.- Hosted UI
/signupis hidden. The “Sign up” link is removed from the hosted UI/loginpage. - User creation continues via
admin-create-user(and via Terraform). - Password reset, admin invite, and existing auth flows are unaffected.
Live probe to confirm:
aws cognito-idp sign-up \
--client-id 6sjjnkaeagnqgkmbl1mr5rtfsr \
--username probe@example.com --password 'NoSignups!2026' \
--region us-east-1
# → NotAuthorizedException: SignUp is not permitted for this user pool
Closed by phenom-infra commit 75afcd8 and issue #72.
Password reset flow
What the user experiences
- Client (mobile app, hosted UI, or web) calls
ForgotPasswordagainst the pool’s app client. - Cognito generates a 6-digit code and invokes the
custom_messageLambda withtriggerSource = "CustomMessage_ForgotPassword". - The Lambda returns a branded HTML body containing the code prominently plus a “Reset password” button linking to
https://www.thephenom.app/reset-password?code={####}&email=<user>. Cognito substitutes the literal{####}placeholder with the actual code before sending to SES. - SES delivers the email from
Phenom <noreply@thephenom.app>(DKIM-signed; details on the SES page). - User enters the code + new password on the reset surface. The client calls
ConfirmForgotPassword. Done.
The custom_message Lambda
PR #71 (merged 2026-05-18) added a per-environment custom_message Lambda:
phenom-dev-cognito-custom-message(shared by phenom-staging and phenom-dev-local)phenom-prod-cognito-custom-message(phenom-prod)
The Lambda intercepts only CustomMessage_ForgotPassword. All other trigger sources (sign-up, admin invite, attribute verification, MFA challenge) pass through untouched, so Cognito uses its built-in defaults for those. The Lambda code lives at environments/{development,production}/lambda-functions/cognito-custom-message/index.js (parallel copies; identical contents).
Lambda env var PASSWORD_RESET_URL controls the link destination. Default https://www.thephenom.app/reset-password (set via local.password_reset_url in each environment’s locals.tf).
The Lambda never sees the real reset code in memory. Cognito performs {####} substitution after the Lambda returns. Useful security property: the code never lands in CloudWatch logs.
Mobile-app integration (what the app needs to do)
The phenom-infra side is complete. The mobile app needs to:
Call
ForgotPasswordwhen the user taps “Forgot password”:await cognito.forgotPassword({ ClientId: COGNITO_CLIENT_ID, // 6sjjnkaeagnqgkmbl1mr5rtfsr for the current live build Username: email, })This triggers the email. The API also returns
CodeDeliveryDetails(destination, medium) which the app should surface (“Code sent to t***@example.com”).Call
ConfirmForgotPasswordwhen the user enters the code + new password:await cognito.confirmForgotPassword({ ClientId: COGNITO_CLIENT_ID, Username: email, ConfirmationCode: code, Password: newPassword, })No callback URL needed.
ForgotPasswordandConfirmForgotPasswordare public Cognito endpoints; they do not use the OAuth callback flow. The client just needsClientId+Username+ (for confirm)ConfirmationCode+Password.Auth flows configured on the staging client (as of 2026-05-19, matching prod):
ALLOW_USER_SRP_AUTH,ALLOW_USER_PASSWORD_AUTH,ALLOW_REFRESH_TOKEN_AUTH. Use SRP for sign-in.
Reset URL destination (live as of 2026-05-19)
https://www.thephenom.app/reset-password is live. The implementation lives in the Phenom-earth/www repo at web/reset-password/ (PR #117, merged 2026-05-19T15:46:30Z).
Behaviour:
GET /reset-passwordreturns HTTP 308 redirect to/reset-password/(trailing-slash convention). Query string is preserved through the redirect, so the Lambda’s URL works as-is without a Lambda change.GET /reset-password/?email=…&code=…returns HTTP 200, renders the form with email readonly + prefilled, code prefilled when the URL value is exactly six digits, focus jumps to the new-password field.- On submit the page POSTs to
https://cognito-idp.us-east-1.amazonaws.com/withX-Amz-Target: AWSCognitoIdentityProviderService.ConfirmForgotPassword. Zero SDK dependency, vanilla JS, ~150 lines. - The page hardcodes the staging hasura client (
6sjjnkaeagnqgkmbl1mr5rtfsr) today because that is the pool the live mobile app build targets. When the prod-pool cutover happens, swap the constant inweb/reset-password/reset-password.jsor add a?cid=URL parameter and a small routing layer. - Cognito error mapping handled for the common cases:
CodeMismatchException,ExpiredCodeException,InvalidPasswordException,LimitExceededException,TooManyFailedAttemptsException,UserNotFoundException. Other errors surface the raw Cognito message.
Verification done end-to-end via Interceptor on ai (logged-in CF Access cookie): production URL serves the form, form prefills correctly, synthetic code submission roundtrips through Cognito and renders the user-friendly error. Success path (valid code + valid password) is not yet round-tripped in automation; it requires reading a fresh code from WorkMail.
Mobile-app users would still benefit from in-app reset (no email click needed). Universal Links / App Links remain an option for a future iteration; they require apple-app-site-association + assetlinks.json served from www plus mobile-app entitlements.
Operations
Test accounts
The following CONFIRMED test users live in each pool (created 2026-05-19 for password-reset validation; pool-distinguishable usernames so the email recipient can tell which pool sent the reset):
| Pool | Test user | Notes |
|---|---|---|
| phenom-staging | test-staging@thephenom.app | CONFIRMED, email_verified |
| phenom-prod | test-prod@thephenom.app | CONFIRMED, email_verified |
| phenom-dev-local | test-devlocal@thephenom.app | CONFIRMED, email_verified |
Mail to *@thephenom.app is routed via SES inbound (inbound-smtp.us-east-1.amazonaws.com MX) to the WorkMail organisation m-85dbc6db1b474331af97f5ce0e777740. Shared initial password is held by on-call; rotate after live validation work and use admin-set-user-password to reset.
Testing playbook
Trigger a forgot-password from the CLI:
aws cognito-idp forgot-password \
--client-id 6sjjnkaeagnqgkmbl1mr5rtfsr \
--username test-staging@thephenom.app \
--region us-east-1
Tail the Lambda log:
aws logs filter-log-events \
--log-group-name /aws/lambda/phenom-dev-cognito-custom-message \
--start-time $(( ($(date +%s) - 300) * 1000 )) \
--region us-east-1
Watch SES delivery metric:
aws cloudwatch get-metric-statistics \
--namespace AWS/SES --metric-name Send \
--start-time $(date -u -d '10 minutes ago' +%FT%TZ) \
--end-time $(date -u +%FT%TZ) \
--period 60 --statistics Sum --region us-east-1
Confirm reset (after the user reads the code from the inbox):
aws cognito-idp confirm-forgot-password \
--client-id 6sjjnkaeagnqgkmbl1mr5rtfsr \
--username test-staging@thephenom.app \
--confirmation-code XXXXXX \
--password 'NewPassword!2026Aa#' \
--region us-east-1
Hosted-UI URLs for manual demos
phenom-staging
https://us-east-1n8go6sbp6.auth.us-east-1.amazoncognito.com/forgotPassword?client_id=6sjjnkaeagnqgkmbl1mr5rtfsr&response_type=token&scope=email+openid+profile&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2F
phenom-prod
https://phenom-prod-hasura-auth.auth.us-east-1.amazoncognito.com/forgotPassword?client_id=8uun49ru7f3fdvmlc12vqig3a&response_type=token&scope=email+openid+profile&redirect_uri=https%3A%2F%2Fwww.thephenom.app%2F
phenom-dev-local
https://phenom-dev-hasura-auth.auth.us-east-1.amazoncognito.com/forgotPassword?client_id=2eq1vf0nvl5o3rha2vshm8j0mn&response_type=token&scope=email+openid+profile&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2F
All three Hosted UI domains are live (HTTP 200 as of 2026-05-19).
CI/CD
Workflows
Chat Infrastructure CI(.github/workflows/chat-ci.ymlin phenom-infra). Plan + test + security + deploy for the development environment. Tier 4 auto-applies a narrow target on push to main:terraform apply -target=module.chat_synapse -target=module.chat_mcp_server. Path filter:modules/chat-*/**andenvironments/development/**.Production Infrastructure CI(.github/workflows/prod-infra-ci.yml, added 2026-05-19). Plan + security + manual apply for the production environment. Plans on every push to main and every PR touchingenvironments/production/**or sharedmodules/**. Apply is never automatic. The operator triggersworkflow_dispatchwithconfirm: CONFIRMand an audit-trailreasonstring. Closes the prod drift-detection gap.
Both workflows authenticate via OIDC to the same IAM role (phenom-dev-github-actions). On 2026-05-19 the role was extended with cognito-idp:Get*, lambda:GetFunction* / ListVersionsByFunction / GetPolicy, secretsmanager:GetResourcePolicy, ses:Get* / List*, sesv2:GetEmailIdentity*, and rds:ListTagsForResource to satisfy AWS provider 6.x refresh calls. Also extended to grant access to the phenom-production-tfstate bucket so the same role can plan against both environments.
Terraform state
- Backends: S3 (
phenom-{development,production}-tfstatebuckets inus-east-1). - DynamoDB state lock (new 2026-05-19): table
terraform-locks. Both backends now declaredynamodb_table = "terraform-locks"andencrypt = true. Prevents concurrent-apply state corruption. Lock acquire/release is visible interraform applyoutput.
Lambda packaging gotcha
data "archive_file" blocks zip the entire source_dir, including untracked files. A stray bun.lock or .DS_Store in a Lambda source directory caused source_code_hash drift between machines. Resolved 2026-05-19 (phenom-infra commit fd2f5bf):
.gitignorenow excludes**/lambda-functions/**/bun.lockand**/.DS_Storeglobally.- Drifted Lambdas (
hasura_action_phenom_handlerdev+prod,hasura_cognito_sync_usersdev+prod,file_validatordev) were re-applied with clean source dirs so deployed zips match repo source.
If you see source_code_hash drift on the next plan, check for untracked files in the Lambda source dir before applying.
Recent commits
phenom-infra:
| Date | Commit | What |
|---|---|---|
| 2026-05-16 | 9bff2ef | PR #69 SES email delivery on all three pools |
| 2026-05-18 | cd3f582 | PR #71 custom_message Lambda with reset URL |
| 2026-05-18 | 9204f46 | IAM extension for AWS provider 6.x refresh |
| 2026-05-19 | 1f20a65 | IAM add cognito-idp:Get* |
| 2026-05-19 | 61d8cab | Docker login user fix (applepublic, not applepublicdotcom) |
| 2026-05-19 | 75afcd8 | Disable self-signup on all three pools (#72) |
| 2026-05-19 | fd2f5bf | Hardening bundle (DDB lock, drift fix, prod CI, client parity, IAM expansion) (#73) |
| 2026-05-19 | 3b69ac8 | prod-infra-ci contents: write permission for commit-comment step |
| 2026-05-19 | dc9890f | Narrow cognito-idp:Get* to specific actions, satisfy Checkov CKV_AWS_287 |
www (the reset page itself):
| Date | Commit | What |
|---|---|---|
| 2026-05-19 | 572c0c9 | PR #117 /reset-password page (HTML + zero-dep vanilla JS) |
phenom-earth-docs:
| Date | PR | What |
|---|---|---|
| 2026-05-19 | PR #276 | scripts/canonicalize-dossier-graph-urls.py + canonicalized graph.json (slug-rule reconciliation against the manifest) |
| 2026-05-19 | PR #275 | this page |
Closed issues
- phenom-infra#70 Add password-reset email URL via custom_message Lambda (closed via PR #71)
- phenom-infra#72 Disable self-signup on all three user pools (closed via commit
75afcd8) - phenom-infra#73 Hardening bundle (closed via commit
fd2f5bf) - www#116
/reset-passwordpage (closed via PR #117 merge)
Known follow-ups (outside the work above)
- Mobile app
ForgotPasswordScreenis a stub.PhenomApp/.../Account/ForgotPasswordScreen.tsx:50hasonPress={() => {}}on the Resend button. The Cognito reset email is delivered, but the mobile app does not yet callForgotPasswordorConfirmForgotPassword. Owner: mobile dev. Once wired, mobile users skip the web reset page entirely. - Upstream graph generator for
disclosure-dossier-<release>-graph.jsonshould produce canonical S3-matching URLs in the first place, makingcanonicalize-dossier-graph-urls.pya belt-and-suspenders defence rather than a hot patch. Generator lives outsidephenom-earth-docs. - Pages Function SigV4 strict encoding.
functions/files/disclosure-dossier/[[path]].tsline 182 usesencodeURIComponentfor the canonical path. That does not strict-encode' ( ) * !(S3 needs%27 %28 %29 %2A %21). Today no canonical S3 key contains any of those, so this is latent rather than active. Worth a smallawsUriEncodehelper to close the gap. - Admin-sandbox
reset-password-form.tsxinphenom-backendreads?email=but not?code=. Adding?code=prefill there is redundant now that the live page onwwwalready handles both, but kept as a known follow-up if the admin-sandbox is ever deployed. - Cloudflare edge negative-cache. Observed during dossier validation: edge served stale 404 from the int-docs Pages Function despite
cache-control: no-storeon error paths.?_=<ts>cache-busting walked around it. Worth confirming whether the no-store header is honoured at the edge. failure_thresholddeprecated onaws_service_discovery_serviceinmodules/ecs/services.tf. Provider warning today, breaking in a future provider major.- Lambda code duplication between
environments/{dev,prod}/lambda-functions/forhasura-cognito-trigger,hasura-cognito-sync-users,hasura-action-phenom-handler,cognito-custom-message. Consolidate intomodules/lambdas/<name>/. invite_message_templatenot set onadmin_create_user_config. Admin-invite emails use plain Cognito boilerplate; should be branded like the password-reset HTML body (a second custom_message Lambda branch,triggerSource === 'CustomMessage_AdminCreateUser').- phenom-mailer Cloudflare Worker still does not exist. Per the 2026-04-18 directive, transactional email should eventually route through
workers/phenom-mailer/for consistency.
See also: Cognito Email via SES for the email-delivery layer.
Maintained by infra-on-call. Update this page when Cognito state changes materially.
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.