Cognito Auth Flows in the SPA

The N.E.S.T. SPA exposes three Cognito-driven password flows — self-service sign-up, forgot-password, and (as of 2026-05-17) the NEW_PASSWORD_REQUIRED challenge for admin-invited users. This page documents each flow’s screen, the underlying 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

FlowWhen it firesCognito APISPA screenRoute
Self-service sign-upNew user fills out a sign-up formsignUp(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 challengeAdmin-invited user signs in for the first time with the temp password from their invite emailauthenticateUser(...) returns newPasswordRequiredcompleteNewPasswordChallenge(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: 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:

RuleCognito settingSPA schema
Minimum lengthminimum_length = 8.min(8)
Lowercase requiredrequire_lowercase = true.regex(/[a-z]/)
Uppercase requiredrequire_uppercase = true.regex(/[A-Z]/)
Number requiredrequire_numbers = true.regex(/\d/)
Symbol requiredrequire_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 Cognito onSuccess
  • authenticateUser → rejects on Cognito onFailure
  • authenticateUser{kind:'newPasswordRequired', user, userAttributes, requiredAttributes} when Cognito challenges
  • authenticateUser → defaults requiredAttributes to [] when Cognito sends null
  • completeNewPasswordChallenge → strips email_verified + email before forwarding
  • completeNewPasswordChallenge → rejects on Cognito onFailure
  • completeNewPasswordChallenge → resolves with the session on success
  • 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