Cognito password reset: ClientId mismatch diagnostic
Why a fresh Cognito password-reset code returns ExpiredCodeException seconds after issue, how the system routes today, and how to diagnose the symptom. Reflects live state as of 2026-05-25.
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, federated login (Google + Apple), and operations.
Reflects live state as of 2026-06-24.
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 |
17 | Mobile app production build (intentional staging-as-prod) |
phenom-prod |
us-east-1_knEL7cqS3 |
6 | nest.thephenom.app SPA + Worker (post cutover), chat.thephenom.app OIDC validator |
phenom-dev-local |
us-east-1_AkG9mnbjA |
6 | Local-dev workstation (localhost:8080), int-docs.thephenom.app CF Access |
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-staging.thephenom.app/_synapse/client/oidc/callback |
| phenom-prod | phenom-prod-hasura-client |
8uun49ru7f3fdvmlc12vqig3a |
https://www.thephenom.app/* |
| phenom-prod | phenom-prod-nest-spa |
5vlgjrab90897c45ls9jkf9s2p |
Public SPA client (no callback; SRP + PASSWORD + REFRESH). |
| phenom-prod | phenom-prod-synapse-oidc |
(see chat-synapse module output) | https://chat.thephenom.app/_synapse/client/oidc/callback |
| 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)All three pools allow self-service SignUp — on for every endpoint (enabled 2026-06-23):
admin_create_user_config {
allow_admin_create_user_only = false
}
SignUp API succeeds — users self-register without admin pre-provisioning./login shows the “Sign up” link.admin-create-user and Terraform-driven creation still work as before.History: these pools previously enforced
allow_admin_create_user_only = true(admin-only creation,SignUpreturnedNotAuthorizedException). That was reversed on 2026-06-23 across all three pools so self-signup is on for every endpoint — reconciled in Terraform (prod via phenom-infra PR #165, staging + dev-local via #166).
Every app client on all three pools offers COGNITO + Google + SignInWithApple,
managed entirely in Terraform (non-prod in environments/development/cognito-federation.tf
cognito-clients-*.tf; prod in environments/production/cognito.tf + cognito-clients-*.tf;
shared module clients in modules/chat-shared). Console-created clients were adopted via
native import {} blocks so there is no console-only drift.Federation is configured at the pool (identity providers) and client
(supported_identity_providers) level — not per Cognito user group. User groups
(nest-access, nest-moderator, etc.) are authorization labels and carry no IdP setting;
once federation is on for a pool, every user in it — any group, or none — can sign in via
Google/Apple. (The <poolId>_Google / <poolId>_SignInWithApple groups Cognito shows on
the prod pool are auto-created federation artifacts, not the mechanism.)
| Pool | Clients federated | Live? |
|---|---|---|
| phenom-prod | 10 / 10 | ✅ applied |
| phenom-staging | 5 / 5 | code merged; apply pending non-prod creds |
| phenom-dev-local | 3 / 3 | code merged; apply pending non-prod creds |
This is deliberate. Prod and non-prod do NOT share Google/Apple secrets.
prod (phenom-prod) |
non-prod (phenom-staging + phenom-dev-local) |
|
|---|---|---|
| Google OAuth client | 102510741200-9g7rb00j1nso9pabn7tohfdig2aj5dfo (“Phenom NEST Web”, GCP project 102510741200) |
separate non-prod client secret, minted 2026-06-24 |
| Google client secret | original (set out-of-band in the console) | distinct secret, not the prod one |
| Apple Services ID | app.thephenom.nest (“Phenom Web Sign In”, team 5VZQAJBZ8H) |
same Services ID, distinct key |
| Apple Sign in key | key 68D568JG5R (“Phenom Signin Key”) |
separate key, minted 2026-06-24 (distinct key_id + .p8) |
Why separate (not reuse prod’s):
.p8 keys are
download-once, and the .p8 for 68D568JG5R was not retained anywhere
(not in 1Password, AWS Secrets Manager, SSM, or on disk). So reuse is impossible.Where the non-prod secrets live: 1Password (op://Phenom/phenom-nonprod-cognito-federation/),
never committed. They are supplied to Terraform only at apply time via
TF_VAR_google_oauth_client_secret and TF_VAR_apple_signin_private_key. The IdP
provider_details map is ignore_changes’d, so the secret material is never held in
code or in a plan diff. The Terraform default = "" on those vars keeps CI plan green
(CI passes no -var); a real cognito apply must export the real values.
For each non-prod pool, the minted credentials must be registered on the provider side:
102510741200, needs Editor/Owner) → OAuth client → Authorized redirect URIs:
https://us-east-1n8go6sbp6.auth.us-east-1.amazoncognito.com/oauth2/idpresponsehttps://phenom-dev-hasura-auth.auth.us-east-1.amazoncognito.com/oauth2/idpresponse5VZQAJBZ8H, needs Admin) → Services ID app.thephenom.nest → Sign in with Apple → Return URLs: the same two …/oauth2/idpresponse URLs.terraform apply is never automatic for cognito. Use a -target-scoped apply of the
cognito resources only (prod has unrelated state drift, issue #129; a full apply could
destroy out-of-band resources). Prod was applied 2026-06-23. Verify with the Hosted UI: a
federated client’s /login shows “Continue with Google / Apple”, and the button round-trips
to a ?code= on the registered callback.
aws cognito-idp describe-user-pool --user-pool-id us-east-1_n8gO6SbP6 --region us-east-1 \
--query 'UserPool.AdminCreateUserConfig.AllowAdminCreateUserOnly'
# → false (self-signup ON)
ForgotPassword against the pool’s app client.custom_message Lambda with triggerSource = "CustomMessage_ForgotPassword".https://www.thephenom.app/reset-password?code={####}&email=<user>&cid=<client>&pid=<pool>. Cognito substitutes the literal {####} placeholder with the actual code before sending to SES.Phenom <noreply@thephenom.app> (DKIM-signed; details on the SES page).ConfirmForgotPassword. Done.custom_message LambdaTwo deployed Lambda functions handle custom messaging:
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 (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 appends cid (event.callerContext.clientId) and pid (event.userPoolId) to the reset URL so any client across any pool can complete its own reset on the same page. See the ClientId mismatch postmortem for the trap that motivated this design.
The Lambda never sees the real reset code in memory. Cognito performs {####} substitution after the Lambda returns, so the code never lands in CloudWatch logs.
The phenom-infra side is complete. The mobile app needs to:
Call ForgotPassword when 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 ConfirmForgotPassword when the user enters the code + new password:
await cognito.confirmForgotPassword({
ClientId: COGNITO_CLIENT_ID,
Username: email,
ConfirmationCode: code,
Password: newPassword,
})
No callback URL needed. ForgotPassword and ConfirmForgotPassword are public Cognito endpoints; they do not use the OAuth callback flow.
Auth flows configured on the staging client: ALLOW_USER_SRP_AUTH, ALLOW_USER_PASSWORD_AUTH, ALLOW_REFRESH_TOKEN_AUTH. Use SRP for sign-in.
https://www.thephenom.app/reset-password is live. The implementation lives in the Phenom-earth/www repo at web/reset-password/.
Behaviour:
GET /reset-password returns HTTP 308 redirect to /reset-password/ (trailing-slash convention). Query string is preserved through the redirect.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.https://cognito-idp.us-east-1.amazonaws.com/ with X-Amz-Target: AWSCognitoIdentityProviderService.ConfirmForgotPassword. Zero SDK dependency, vanilla JS.?cid= from the URL and uses it as the ClientId for ConfirmForgotPassword. When ?cid= is absent it falls back to the staging hasura client 6sjjnkaeagnqgkmbl1mr5rtfsr to remain backward compatible with reset emails sent before the Lambda update.CodeMismatchException, ExpiredCodeException, InvalidPasswordException, LimitExceededException, TooManyFailedAttemptsException, UserNotFoundException. Other errors surface the raw Cognito message.Mobile-app users would 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.
The following CONFIRMED test users live in each pool:
| 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.
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
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.
Chat Infrastructure CI (.github/workflows/chat-ci.yml in 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 covers modules/chat-*/** and environments/development/**.Production Infrastructure CI (.github/workflows/prod-infra-ci.yml): Plan + security + manual apply for the production environment. Plans on every push to main and every PR touching environments/production/** or shared modules/**. Apply is never automatic. The operator triggers workflow_dispatch with confirm: CONFIRM and an audit-trail reason string.Both workflows authenticate via OIDC to the IAM role phenom-dev-github-actions.
phenom-{development,production}-tfstate buckets in us-east-1).terraform-locks. Both backends declare dynamodb_table = "terraform-locks" and encrypt = true. Prevents concurrent-apply state corruption.data "archive_file" blocks zip the entire source_dir, including untracked files. A stray bun.lock or .DS_Store in a Lambda source directory causes source_code_hash drift between machines.
Resolution: .gitignore excludes **/lambda-functions/**/bun.lock and **/.DS_Store globally. If you see source_code_hash drift on the next plan, check for untracked files in the Lambda source dir before applying.
ForgotPasswordScreen is a stub. PhenomApp/.../Account/ForgotPasswordScreen.tsx:50 has onPress={() => {}} on the Resend button. The Cognito reset email is delivered, but the mobile app does not yet call ForgotPassword or ConfirmForgotPassword. Owner: mobile dev. Once wired, mobile users skip the web reset page entirely.disclosure-dossier-<release>-graph.json should produce canonical S3-matching URLs in the first place, making canonicalize-dossier-graph-urls.py a belt-and-suspenders defence rather than a hot patch.functions/files/disclosure-dossier/[[path]].ts line 182 uses encodeURIComponent for 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.reset-password-form.tsx in phenom-backend reads ?email= but not ?code=. Adding ?code= prefill there is redundant now that the live page on www already handles both, but kept as a known follow-up if the admin-sandbox is ever deployed.cache-control: no-store on error paths. ?_=<ts> cache-busting walked around it. Worth confirming whether the no-store header is honoured at the edge.failure_threshold deprecated on aws_service_discovery_service in modules/ecs/services.tf. Provider warning today, breaking in a future provider major.environments/{dev,prod}/lambda-functions/ for hasura-cognito-trigger, hasura-cognito-sync-users, hasura-action-phenom-handler, cognito-custom-message. Consolidate into modules/lambdas/<name>/.invite_message_template not set on admin_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').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.
Why a fresh Cognito password-reset code returns ExpiredCodeException seconds after issue, how the system routes today, and how to diagnose the symptom. Reflects live state as of 2026-05-25.
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.
© 2026 Phenom Earth