Cognito Auth Flows in the SPA
amazon-cognito-identity-js call, and the auth-store contract.
Source:
Cognito pool us-east-1_knEL7cqS3 confirmed; amazon-cognito-identity-js API matches documented flows; NEW_PASSWORD_REQUIRED challenge confirmed from prior session auth testing
C2PA signed · SanMarcSoft AI content credential
TL;DR. Cognito has three distinct password flows, each requiring its own SPA screen. The third (admin-invite first-sign-in) handles users created via AdminCreateUser who must set a new password on their first sign-in.
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: submit /sign-in
SigningIn --> Authenticated: kind session
SigningIn --> ChallengeActive: kind newPasswordRequired
SigningIn --> Anon: onFailure
ChallengeActive --> Authenticated: completeNewPassword
ChallengeActive --> Anon: refresh without challenge state
Anon --> Reset: tap Forgot password
Reset --> EmailedCode: forgotPassword
EmailedCode --> Anon: confirmPassword then /sign-in
Authenticated --> Anon: logout
Why 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
- 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- N.E.S.T. Access Gate –
nest-accessgroup enforcement applied after sign-in succeeds
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.