Cognito Authentication Flows

How sign-in, password reset, signup, and federated login (Google + Sign in with Apple) behave across the three Phenom Cognito pools. Includes the custom_message Lambda, the prod-vs-non-prod federation credential model, mobile-app integration notes, and the operations playbook. Reflects live state as of 2026-06-24.

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.

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 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)

Self-signup enabled

All three pools allow self-service SignUpon 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.
  • Hosted UI /login shows the “Sign up” link.
  • admin-create-user and Terraform-driven creation still work as before.
  • Password reset, admin invite, and existing auth flows are unaffected.

History: these pools previously enforced allow_admin_create_user_only = true (admin-only creation, SignUp returned NotAuthorizedException). 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).

Federated login (Google + Sign in with Apple)

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

Credential model — prod and non-prod use separate provider credentials

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):

  1. The prod secrets are unrecoverable. Google removed secret viewing — an existing OAuth client secret can no longer be read back, only rotated. Apple .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.
  2. Cleaner blast radius. A leaked non-prod secret cannot compromise prod sign-in.

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.

Provider-side setup (out-of-band — Terraform can’t do these)

For each non-prod pool, the minted credentials must be registered on the provider side:

  • Google (GCP project 102510741200, needs Editor/Owner) → OAuth client → Authorized redirect URIs:
    • staging → https://us-east-1n8go6sbp6.auth.us-east-1.amazoncognito.com/oauth2/idpresponse
    • dev-local → https://phenom-dev-hasura-auth.auth.us-east-1.amazoncognito.com/oauth2/idpresponse
  • Apple (Developer team 5VZQAJBZ8H, needs Admin) → Services ID app.thephenom.nestSign in with Apple → Return URLs: the same two …/oauth2/idpresponse URLs.

Applying federation

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.

Live probe — confirm self-signup is on

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)

Password reset flow

What the user experiences

  1. Client (mobile app, hosted UI, or web) calls ForgotPassword against the pool’s app client.
  2. Cognito generates a 6-digit code and invokes the custom_message Lambda with triggerSource = "CustomMessage_ForgotPassword".
  3. 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>&cid=<client>&pid=<pool>. Cognito substitutes the literal {####} placeholder with the actual code before sending to SES.
  4. SES delivers the email from Phenom <noreply@thephenom.app> (DKIM-signed; details on the SES page).
  5. User enters the code + new password on the reset surface. The client calls ConfirmForgotPassword. Done.

The custom_message Lambda

Two 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.

Mobile-app integration

The phenom-infra side is complete. The mobile app needs to:

  1. 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”).

  2. Call ConfirmForgotPassword when the user enters the code + new password:

    await cognito.confirmForgotPassword({
      ClientId: COGNITO_CLIENT_ID,
      Username: email,
      ConfirmationCode: code,
      Password: newPassword,
    })
    
  3. No callback URL needed. ForgotPassword and ConfirmForgotPassword are public Cognito endpoints; they do not use the OAuth callback flow.

  4. Auth flows configured on the staging client: ALLOW_USER_SRP_AUTH, ALLOW_USER_PASSWORD_AUTH, ALLOW_REFRESH_TOKEN_AUTH. Use SRP for sign-in.

Reset URL destination

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.
  • On submit the page POSTs to https://cognito-idp.us-east-1.amazonaws.com/ with X-Amz-Target: AWSCognitoIdentityProviderService.ConfirmForgotPassword. Zero SDK dependency, vanilla JS.
  • The page reads ?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.
  • Cognito error mapping handled for the common cases: 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.

Operations

Test accounts

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.

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.

CI/CD

Workflows

  • 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.

Terraform state

  • Backends: S3 (phenom-{development,production}-tfstate buckets in us-east-1).
  • DynamoDB state lock: table terraform-locks. Both backends declare dynamodb_table = "terraform-locks" and encrypt = true. Prevents concurrent-apply state corruption.

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 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.

Known follow-ups

  • Mobile app 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.
  • Upstream graph generator for 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.
  • Pages Function SigV4 strict encoding. 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.
  • Admin-sandbox 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.
  • Cloudflare edge negative-cache. Observed during dossier validation: edge served stale 404 from the int-docs Pages Function despite 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.
  • Lambda code duplication between 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').
  • phenom-mailer Cloudflare Worker does not yet 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.


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.