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 lockdown, and operations.
Reflects live state as of 2026-05-25.
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 enforce admin-only user creation:
admin_create_user_config {
allow_admin_create_user_only = true
}
SignUp API returns NotAuthorizedException: SignUp is not permitted for this user pool./signup is hidden. The “Sign up” link is removed from the hosted UI /login page.admin-create-user (and via Terraform).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
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