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.
Audit stamp: Verified — 2026-06-19 — The Narrator
Verified · 2026-06-19 · The Narrator
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 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: 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 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
  • 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 Gatenest-access group enforcement applied after sign-in succeeds