Authentication Flows

Complete authentication flows including email/password, social login, session management, and refresh tokens

Overview

Zooly Auth provides multiple authentication flows for different use cases:

  • Redirect-based login: Standard flow for web apps using returnTo parameter
  • Popup login: No-redirect login in a popup window, for *.zooly.ai apps and localhost
  • Email/Password: Traditional authentication with email verification
  • Social Login: Google and Apple OAuth (Phase 2)
  • Guest Checkout: Email-only identity for purchases without registration

All flows result in the same session cookies shared across all *.zooly.ai subdomains. The popup flow reuses the same email/password and social endpoints described below — see Popup Login (No-Redirect).

A popup / inline login (no redirects, for mini-apps) was part of the original design but has not been built — see Popup / No-Redirect Login.


Redirect + returnTo Flow

The standard authentication flow uses redirects with a returnTo parameter to bring users back to their original destination after login.

Entry Flow

Each app redirects unauthenticated users to:

https://auth.zooly.ai?returnTo=https://zooly.ai/my-app

returnTo Validation

To prevent open redirects, returnTo URLs are validated by validateReturnTo:

  • Relative paths and same-origin URLs are allowed, and returned as a relative path
  • Cross-origin URLs on zooly.ai or any *.zooly.ai subdomain are allowed (they already share the auth cookie domain), and returned as an absolute URL
  • localhost / 127.0.0.1 are allowed for local development
  • javascript:, data: and protocol-relative URLs like //evil.com are rejected
  • Anything else returns null, and the user is sent to the main site instead

The value also survives multi-step flows (signup → email confirm → login, OAuth round-trips, reloads): it is backed up per-tab in sessionStorage, and the social login route additionally stores it in a one-shot auth-return-to cookie that is cleared once the callback consumes it.

Post-Login Flow

After successful authentication:

  1. Auth app sets session cookies for .zooly.ai (both ID token and refresh token)
  2. Auth app redirects the user to the validated returnTo URL, or to the main site (NEXT_PUBLIC_MAIN_SITE_URL) when there is no valid target — the auth app is never a final destination (ZLY-1481)

Example Flow

1. User visits: https://app.zooly.ai/dashboard
2. App detects no session → Redirects to: https://auth.zooly.ai?returnTo=https://app.zooly.ai/dashboard
3. User logs in at auth.zooly.ai
4. Auth app sets cookies for .zooly.ai (auth-token and auth-refresh-token)
5. Auth app redirects to: https://app.zooly.ai/dashboard
6. User arrives back at dashboard with valid session

Email/Password Authentication Flow

Sign Up Flow

sequenceDiagram participant User participant LoginPage participant AuthAPI participant Cognito participant DynamoDB User->>LoginPage: Enters email LoginPage->>LoginPage: Shows SignUpForm User->>LoginPage: Enters password + display name LoginPage->>AuthAPI: POST /api/auth/signup AuthAPI->>Cognito: signUp(email, password) Cognito-->>AuthAPI: User created (unconfirmed) AuthAPI->>DynamoDB: upsertIdentity (if new) AuthAPI->>DynamoDB: linkCognitoIdentity (if guest exists) Cognito->>User: Sends verification code via email AuthAPI-->>LoginPage: Success LoginPage->>LoginPage: Shows ConfirmSignUpForm User->>LoginPage: Enters verification code LoginPage->>AuthAPI: POST /api/auth/confirm AuthAPI->>Cognito: confirmSignUp(email, code) Cognito-->>AuthAPI: User confirmed AuthAPI->>AuthAPI: signIn(email, password) Cognito-->>AuthAPI: idToken + refreshToken AuthAPI-->>LoginPage: Sets cookies + redirects to returnTo

Steps:

  1. User enters email → LoginForm
  2. User clicks "Sign up" → SignUpForm
  3. User enters password and optional display name
  4. POST /api/auth/signup
    • Creates Cognito user (unconfirmed)
    • Creates/updates DynamoDB identity
    • Links guest identity if email matches (linkCognitoIdentity)
  5. Cognito sends verification code to email
  6. User sees ConfirmSignUpForm
  7. User enters verification code
  8. POST /api/auth/confirm
    • Confirms Cognito user
    • Automatically signs in user
    • Sets both session cookies (auth-token and auth-refresh-token)
    • Redirects to returnTo

Login Flow

sequenceDiagram participant User participant LoginPage participant AuthAPI participant Cognito participant DynamoDB User->>LoginPage: Enters email LoginPage->>LoginPage: Shows PasswordForm User->>LoginPage: Enters password LoginPage->>AuthAPI: POST /api/auth/login AuthAPI->>Cognito: signIn(email, password) Cognito-->>AuthAPI: idToken + refreshToken AuthAPI->>AuthAPI: verifyToken(idToken) AuthAPI->>DynamoDB: getIdentity(sub) AuthAPI->>DynamoDB: linkCognitoIdentity (if guest exists) AuthAPI-->>LoginPage: Sets cookies + redirects to returnTo

Steps:

  1. User enters email → LoginForm
  2. User clicks "Continue" → PasswordForm
  3. User enters password
  4. POST /api/auth/login
    • Authenticates with Cognito (USER_PASSWORD_AUTH flow)
    • Verifies ID token
    • Links guest identity if email matches (linkCognitoIdentity)
    • Sets both session cookies (auth-token and auth-refresh-token)
    • Redirects to returnTo

Forgot Password Flow

sequenceDiagram participant User participant LoginPage participant AuthAPI participant Cognito User->>LoginPage: Clicks "Forgot password" LoginPage->>LoginPage: Shows ForgotPasswordForm User->>LoginPage: Enters email LoginPage->>AuthAPI: POST /api/auth/forgot-password AuthAPI->>Cognito: forgotPassword(email) Cognito->>User: Sends reset code via email AuthAPI-->>LoginPage: Success LoginPage->>LoginPage: Shows ResetPasswordForm User->>LoginPage: Enters code + new password LoginPage->>AuthAPI: POST /api/auth/reset-password AuthAPI->>Cognito: confirmForgotPassword(email, code, newPassword) Cognito-->>AuthAPI: Password reset successful AuthAPI-->>LoginPage: Success - user can now login

Steps:

  1. User clicks "Forgot password" → ForgotPasswordForm
  2. User enters email
  3. POST /api/auth/forgot-password
    • Sends reset code to email via Cognito
  4. User sees ResetPasswordForm
  5. User enters code and new password
  6. POST /api/auth/reset-password
    • Resets password in Cognito
    • User can now login with new password

Social Login Flow (Google/Apple)

OAuth Flow

sequenceDiagram participant User participant AuthApp participant Cognito participant Google/Apple participant AuthAPI User->>AuthApp: Clicks "Continue with Google" AuthApp->>AuthAPI: GET /api/auth/social/google?returnTo=... AuthAPI->>Cognito: Generate hosted UI URL AuthAPI-->>AuthApp: Redirect to Cognito hosted UI AuthApp->>Cognito: User authenticates with Google Cognito->>Google/Apple: OAuth flow Google/Apple-->>Cognito: Authorization code Cognito-->>AuthApp: Redirect to /api/auth/callback?code=... AuthApp->>AuthAPI: GET /api/auth/callback?code=... AuthAPI->>Cognito: exchangeCodeForTokens(code) Cognito-->>AuthAPI: idToken + refreshToken AuthAPI->>AuthAPI: verifyToken(idToken) AuthAPI->>DynamoDB: upsertIdentity / linkCognitoIdentity AuthAPI-->>AuthApp: Sets cookies + redirects to returnTo

Steps:

  1. User clicks "Continue with Google/Apple" button
  2. GET /api/auth/social/[provider]?returnTo=...
    • Generates Cognito hosted UI URL with OAuth parameters
    • Stores returnTo in cookie for callback
    • Redirects user to Cognito hosted UI
  3. User authenticates with Google/Apple at Cognito
  4. Cognito redirects to /api/auth/callback?code=...
  5. GET /api/auth/callback
    • Exchanges authorization code for tokens (exchangeCodeForTokens)
    • Verifies ID token
    • Creates/updates DynamoDB identity
    • Links guest identity if email matches
    • Sets both session cookies (auth-token and auth-refresh-token)
    • Redirects to returnTo

Note: Social login is currently implemented and working. The flow uses the same session cookies as email/password authentication.


Guest User Linking

When a user registers or logs in with an email that was previously used for a guest checkout:

sequenceDiagram participant User participant AuthAPI participant Cognito participant DynamoDB User->>AuthAPI: Login/Signup with email AuthAPI->>Cognito: Authenticate Cognito-->>AuthAPI: idToken (contains email) AuthAPI->>DynamoDB: findIdentityByEmail(email) DynamoDB-->>AuthAPI: Guest identity found AuthAPI->>DynamoDB: linkCognitoIdentity(user_id, cognito_sub) Note over DynamoDB: Sets cognito_sub, preserves user_id AuthAPI-->>User: Session created (linked identity)

Process:

  1. System checks for existing identity with matching guest_email
  2. If found, links Cognito identity to existing user_id using linkCognitoIdentity
  3. Sets cognito_sub on existing identity
  4. Preserves all purchase history and data (same user_id)

This enables:

  • Guest purchase now
  • Optional registration later
  • Full purchase history visible after login

Session Management

Session Creation

Sessions are created when:

  • User successfully logs in (POST /api/auth/login)
  • User completes signup and email verification (POST /api/auth/confirm)
  • User completes social login OAuth callback (GET /api/auth/callback)

Session Cookies

Two cookies are set for each session:

auth-token (ID Token)

  • Content: Cognito ID token (JWT)
  • Domain: .zooly.ai (shared across all subdomains)
  • Max-Age: 90 days (~3 months)
  • HttpOnly: true (not accessible to JavaScript)
  • Secure: true (HTTPS only in production)
  • SameSite: Lax
  • Token Validity: 24 hours (Cognito maximum)

auth-refresh-token (Refresh Token)

  • Content: Cognito refresh token (not a JWT)
  • Domain: .zooly.ai (shared across all subdomains)
  • Max-Age: 90 days (~3 months)
  • HttpOnly: true (not accessible to JavaScript)
  • Secure: true (HTTPS only in production)
  • SameSite: Lax
  • Token Validity: 90 days

Important: Both cookies are HttpOnly and only accessible server-side. The refresh token is never exposed to client-side JavaScript.

Automatic Token Refresh

When the ID token expires (after 24 hours), the system automatically refreshes it using the refresh token:

sequenceDiagram participant Browser participant AuthAPI participant Cognito Browser->>AuthAPI: GET /api/me (with cookies) AuthAPI->>AuthAPI: verifyToken(idToken) Note over AuthAPI: Token expired AuthAPI->>AuthAPI: Read auth-refresh-token cookie AuthAPI->>Cognito: REFRESH_TOKEN_AUTH(refreshToken) Cognito-->>AuthAPI: new idToken + accessToken AuthAPI->>AuthAPI: verifyToken(newIdToken) AuthAPI-->>Browser: User profile + Set-Cookie: auth-token=newIdToken Note over Browser: Cookie updated automatically

How it works:

  1. User makes request to /api/me (or any protected endpoint)
  2. Server attempts to verify ID token
  3. If token is expired:
    • Server reads auth-refresh-token cookie
    • Calls Cognito REFRESH_TOKEN_AUTH flow
    • Receives new ID token and Access token
    • Verifies new ID token
    • Updates auth-token cookie in response
  4. User continues seamlessly (no redirect or re-authentication)

Key points:

  • Refresh happens transparently on the server
  • No redirects or user interaction required
  • Users stay logged in for the full 90-day refresh token lifetime
  • Refresh only occurs when token is expired (not on invalid/malformed tokens)

Session Verification

Apps verify sessions by:

  1. Reading ID token from auth-token cookie
  2. Validating JWT signature using JWKS (AWS verifier library)
  3. Checking expiration (auto-refreshes if expired)
  4. Extracting user info (sub, email) from token claims
  5. Looking up user profile and roles from DynamoDB

JWT Verification:

  • Uses aws-jwt-verify library with JWKS
  • Verifies issuer (iss), audience (aud), expiration (exp)
  • Validates token signature against Cognito public keys
  • No shared secrets required

Token Verification and Refresh Logic

The verifyOrRefreshToken() helper function implements the refresh flow:

  1. First attempt: Verify the ID token using JWKS
  2. On expiration: If token is expired and a refresh token is available, automatically refresh the ID token using REFRESH_TOKEN_AUTH flow
  3. On invalid token: If token is malformed or invalid (not just expired), return 401 immediately without attempting refresh
  4. Cookie update: If refresh succeeds, update the auth-token cookie with the new ID token in the response

This ensures:

  • Expired tokens are automatically refreshed (transparent to user)
  • Invalid tokens are rejected immediately (security best practice)
  • Refresh only happens when appropriate (not on malformed tokens)

Apps that don't want to navigate the user to auth.zooly.ai can authenticate through a popup window instead. It reuses the same Cognito setup, session cookies, and login/signup/reset forms as the redirect flow above — only how the result gets back to the calling app is different.

Flow

sequenceDiagram participant App as ConsumerApp participant Popup as AuthPopup participant Cognito App->>Popup: window.open(authUrl + "/popup?origin=" + appOrigin) alt Email / password Popup->>Popup: POST /api/auth/login sets .zooly.ai cookies else Social Popup->>Cognito: /api/auth/social/google?returnTo=/popup-complete?origin=... Cognito->>Popup: /api/auth/callback redirects to /popup-complete end Popup-->>App: postMessage({ type: "zooly-auth:success" }) Popup->>Popup: window.close() App->>App: GET /api/me with credentials: include

The popup never hands back tokens — it only signals "done". The calling app re-reads the session from /api/me, so the cookie stays the single source of truth.

Using it

import { loginWithPopup } from '@zooly/auth-client';

async function handleLoginClick() {
  // Call synchronously inside the click handler, before any `await` —
  // otherwise Safari and other browsers block the popup.
  const result = await loginWithPopup({ authUrl: 'https://auth.zooly.ai' });

  switch (result.status) {
    case 'authenticated':
      // result.user is available; re-render as logged in.
      break;
    case 'popup_blocked':
      // Fall back to a redirect, e.g. redirectToLogin('https://auth.zooly.ai').
      break;
    case 'cancelled':
      // User closed the popup, or it timed out (5 minutes by default).
      break;
  }
}

loginWithPopup():

  • Opens {authUrl}/popup?origin={callerOrigin} in a ~480×720 window.
  • Listens for a postMessage from the popup, accepting only messages whose origin matches authUrl's origin and whose data.type is "zooly-auth:success".
  • Also polls /api/me roughly once a second while the popup is open — a message-independent fallback for when a social provider's OAuth page severs window.opener (see Design Caveats below).
  • Resolves { status: "authenticated", user } (re-fetched from /api/me), { status: "cancelled" } (closed by the user, or timed out), or { status: "popup_blocked" } (the browser blocked window.open — the caller should fall back to a redirect).

See Client Integration for the full @zooly/auth-client API, including the shared fetchUser / logout / redirectToLogin session helpers.

Auth app routes

  • /popup?origin=<caller-origin> — validates origin against the same allowlist as returnTo (zooly.ai, *.zooly.ai, localhost) via validatePopupOrigin(), checks /api/me, and renders a compact login shell (LoginPopup) supporting all six views (email, password, signup, confirm-signup, forgot-password, reset-password). If the origin is invalid it renders an error and never posts a message. If the user is already authenticated it signals success immediately instead of showing the form.
  • /popup-complete?origin=<caller-origin> — lands the social-login round trip. Cognito's OAuth callback (/api/auth/callback) redirects here after a successful social sign-in; this page validates origin the same way, posts the success message, and calls window.close().

Both routes post { type: "zooly-auth:success" } to the validated caller origin — never to event.origin or an unvalidated value — and never include tokens in the message. /api/auth/login, /api/auth/signup, /api/auth/social/[provider] and /api/auth/callback did not need to change: the popup runs same-origin with the auth app, so it authenticates exactly like the full-page flow and just changes how it reports completion.

Design Caveats

  • Cross-Origin-Opener-Policy can sever window.opener. If a social provider's OAuth page sends an enforcing COOP header, the popup loses its reference to the opener mid-flow and postMessage never arrives. As of this writing, Cognito's own Hosted UI sends no COOP header, and Google's OAuth endpoint sends only Cross-Origin-Opener-Policy-Report-Only: same-origin (monitoring, not enforcing) — so the message path works today. The /api/me poll in loginWithPopup() is the safety net if that ever changes: it runs in the calling window and never depends on the popup's window.opener.
  • A hidden-iframe flow is still not viable. Cognito's Hosted UI refuses to be framed, so social login can't complete inside an iframe, and third-party cookie restrictions (Safari ITP, etc.) break iframe-based silent auth for any host outside *.zooly.ai anyway. A popup (a real top-level window) is the only workable no-redirect route for social login.
  • A genuinely inline, embedded login form is out of scope. /api/auth/login, /api/auth/signup, /api/auth/confirm, /api/auth/forgot-password and /api/auth/reset-password still have no CORS headers, so nothing outside the auth app can POST to them directly — the popup works around this by running same-origin with the auth app instead. Only /api/me and /api/auth/logout are CORS-enabled (gated by ALLOWED_DOMAINS_CORS), which is what the popup's /api/me poll and any consumer's own session check rely on.

Manual QA page

apps/zooly-app has a standalone page for exercising @zooly/auth-client without going through a real feature flow: /dev/auth-popup-test (not linked from any nav). It calls loginWithPopup(), fetchUser(), logout(), and redirectToLogin() directly against buttons and prints the raw result / /api/me JSON, so authenticated, cancelled, and popup_blocked are all easy to reproduce on demand. Two real consumers wired into packages/offers/client remain the way to test the popup end-to-end: the fast-signup flow (/talent/fast-signup in zooly-app, click through to the Signup step, then "Continue with email"/"Continue with Gmail") and the brand chat login gate on a talent's z-link (/z/:slug, chat as a guest until the gate appears, then "Sign in to continue") — the latter refreshes the auth context in place afterward instead of navigating away, so the transcript stays on screen.


Logout

Local Logout

Logout clears both session cookies:

POST /api/auth/logout

This removes both auth-token and auth-refresh-token cookies from the current browser. The cookies are cleared by setting them to expire immediately (Max-Age=0).

Note: This removes the session from the current browser but does not invalidate tokens server-side. Tokens remain valid until they expire naturally (24 hours for ID tokens, 90 days for refresh tokens).

Global Sign-Out

Global sign-out (invalidating tokens server-side via Cognito) is not required for the current security posture but can be implemented if needed.


Testing Refresh Token Flow

The refresh token implementation includes test endpoints for validation:

/api/test/refresh-flow

Reads both cookies and attempts to verify/refresh the ID token:

fetch('/api/test/refresh-flow', { credentials: 'include' })
  .then(r => r.json())
  .then(console.log)

Returns:

  • Token presence (idTokenPresent, refreshTokenPresent)
  • Verification result (valid, expired, invalid)
  • Refresh attempt status (refreshAttempted, refreshSucceeded, newTokenIssued)
  • User info and identity data

/api/test/force-refresh

Forces a refresh token flow by directly calling refreshTokens():

fetch('/api/test/force-refresh', { credentials: 'include' })
  .then(r => r.json())
  .then(console.log)

Validates:

  • Refresh token cookie is present and readable
  • Cognito accepts the refresh token (REFRESH_TOKEN_AUTH flow)
  • New tokens are received and verified
  • New ID token cookie is set correctly

Note: To test automatic refresh without waiting 24 hours, temporarily reduce Cognito ID token validity using AWS CLI (see setup documentation for details).


Long-Lived Sessions: 3 Months

Requirement

Users stay signed in for up to ~3 months without manual re-authentication.

Implementation

  • ID tokens: Stored in auth-token cookie with Max-Age of 90 days
  • Refresh tokens: Stored in auth-refresh-token cookie with Max-Age of 90 days
  • ID/Access token validity: 24 hours (Cognito maximum)
  • Refresh token validity: 90 days
  • Automatic refresh: When /api/me (or other protected endpoints) detects an expired ID token, it automatically uses the refresh token to get a new ID token and updates the cookie
  • Error handling: Token verification distinguishes between expired tokens (which trigger refresh) and invalid/malformed tokens (which return 401 immediately without refresh attempt)

Result: Users stay logged in for the full 90-day refresh token lifetime without manual re-authentication, as long as they make requests at least once every 24 hours (ID token lifetime).


Identity Storage

Identity data is split between two systems:

  • Cognito: Email (source of truth), authentication credentials
  • DynamoDB: Profile data (display_name, avatar_url, roles, guest_email)

Important: Email is the source of truth in Cognito. It is read from the ID token and never stored in DynamoDB. This ensures email changes in Cognito are immediately reflected without stale data in DynamoDB.

The sub claim (Cognito user ID) serves as the identity anchor and primary key for DynamoDB lookups.