Cognito Auth Flows in the SPA
amazon-cognito-identity-js call, and the auth-store contract.TL;DR. Cognito has three distinct password flows. Each needs its own SPA screen. The third (admin-invite first-sign-in) was missing until 2026-05-17 and broke first-sign-in for users created via AdminCreateUser.
The three flows
| Flow | When it fires | Cognito API | SPA screen | Route |
|---|---|---|---|---|
| Self-service sign-up | New user fills out a sign-up form | signUp(email, password) → emails verification code → confirmRegistration(code) | “Sign up” → “Enter verification code” | /sign-up, /otp |
| Forgot password (CONFIRMED user) | Existing user clicks “Forgot password” | forgotPassword(email) → emails reset code → confirmPassword(code, newPassword) | “Forgot password” → “Reset password” | /forgot-password, /reset-password |
| NEW_PASSWORD_REQUIRED challenge | Admin-invited user signs in for the first time with the temp password from their invite email | authenticateUser(...) returns newPasswordRequired → completeNewPasswordChallenge(newPassword, attrs) | “Set a new password” | /set-new-password |
The third flow is the easiest to forget because it doesn’t get a separate user action — it interrupts the regular /sign-in submit when Cognito decides the user is in FORCE_CHANGE_PASSWORD status. If the SPA doesn’t render a /set-new-password screen, admin-invited users see an opaque “Password change required. Please contact an admin.” error and are dead-ended.
State machine
stateDiagram-v2
[*] --> Anon
Anon --> SigningIn: enter email + password on /sign-in
SigningIn --> Authenticated: kind:'session'
SigningIn --> ChallengeActive: kind:'newPasswordRequired'
SigningIn --> Anon: onFailure (wrong password, etc.)
ChallengeActive --> Authenticated: completeNewPassword(newPwd)
ChallengeActive --> Anon: page refresh / deep-link without challenge state
Anon --> Reset: click "Forgot password"
Reset --> EmailedCode: forgotPassword(email)
EmailedCode --> Anon: confirmPassword(code, newPwd) → /sign-in
Authenticated --> Anon: logoutWhy the auth store holds the challenge
The newPasswordRequired callback fires inside the CognitoUser instance’s authenticateUser call. That CognitoUser instance holds the server-side challenge state internally — Cognito knows “this user attempted auth, awaiting challenge response.” The next call must be made on the same CognitoUser instance via completeNewPasswordChallenge. New instances start fresh; Cognito won’t accept the challenge response.
Two routes need to share that instance. The SPA solves this by parking it in the Zustand auth store:
// stores/authStore.ts
interface PasswordChallenge {
user: CognitoUser // ← the instance with live challenge state
email: string
userAttributes: Record<string, string>
requiredAttributes: string[]
}
// login() returns 'signed_in' | 'new_password_required'
// completeNewPassword() uses the stored CognitoUser to finish
When /set-new-password mounts, it reads passwordChallenge from the store and calls completeNewPassword(newPwd). If the store has no challenge (user navigated to the URL directly without a fresh sign-in attempt), the screen redirects them back to /sign-in.
Password policy alignment
The set-new-password form’s zod schema mirrors the Cognito user pool’s password policy declared in phenom-infra/environments/{development,production}/cognito.tf:
| Rule | Cognito setting | SPA schema |
|---|---|---|
| Minimum length | minimum_length = 8 | .min(8) |
| Lowercase required | require_lowercase = true | .regex(/[a-z]/) |
| Uppercase required | require_uppercase = true | .regex(/[A-Z]/) |
| Number required | require_numbers = true | .regex(/\d/) |
| Symbol required | require_symbols = true | .regex(/[^A-Za-z0-9]/) |
Keep these in sync if you ever change the policy in cognito.tf — the form validates client-side first; the SPA shouldn’t accept a password Cognito will reject.
Attribute-stripping gotcha
Cognito sends back userAttributes in the newPasswordRequired callback that includes read-only fields like email_verified. If you echo those back unchanged to completeNewPasswordChallenge, Cognito returns InvalidParameterException: User.email_verified: Attribute cannot be updated. (gh issue aws-amplify/amplify-js#5114).
lib/cognito.ts:completeNewPasswordChallenge strips both email_verified and email defensively before forwarding. If you ever need to pass back custom:* required attributes the user filled in, add them to the attributes argument — the helper merges them on top.
Test coverage
src/lib/cognito.test.ts mocks amazon-cognito-identity-js and covers:
authenticateUser→{kind:'session'}on CognitoonSuccessauthenticateUser→ rejects on CognitoonFailureauthenticateUser→{kind:'newPasswordRequired', user, userAttributes, requiredAttributes}when Cognito challengesauthenticateUser→ defaultsrequiredAttributesto[]when Cognito sendsnullcompleteNewPasswordChallenge→ stripsemail_verified+emailbefore forwardingcompleteNewPasswordChallenge→ rejects on CognitoonFailurecompleteNewPasswordChallenge→ resolves with the session on success
Related
- phenom-backend PR #246 — the PR that added
/set-new-password - Cognito Email via SES — how the invite + reset emails actually leave AWS
phenom-infra/environments/{development,production}/cognito.tf— pool config + password policy + email_configuration
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.