Complete authentication flows including email/password, social login, session management, and refresh tokens
Zooly Auth provides multiple authentication flows for different use cases:
returnTo parameter*.zooly.ai apps and localhostAll 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.
The standard authentication flow uses redirects with a returnTo parameter to bring users back to their original destination after login.
Each app redirects unauthenticated users to:
https://auth.zooly.ai?returnTo=https://zooly.ai/my-app
To prevent open redirects, returnTo URLs are validated by validateReturnTo:
zooly.ai or any *.zooly.ai subdomain are allowed (they already share the auth cookie domain), and returned as an absolute URLlocalhost / 127.0.0.1 are allowed for local developmentjavascript:, data: and protocol-relative URLs like //evil.com are rejectednull, and the user is sent to the main site insteadThe 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.
After successful authentication:
.zooly.ai (both ID token and refresh token)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)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
Steps:
LoginFormSignUpFormPOST /api/auth/signup
linkCognitoIdentity)ConfirmSignUpFormPOST /api/auth/confirm
auth-token and auth-refresh-token)returnToSteps:
LoginFormPasswordFormPOST /api/auth/login
USER_PASSWORD_AUTH flow)linkCognitoIdentity)auth-token and auth-refresh-token)returnToSteps:
ForgotPasswordFormPOST /api/auth/forgot-password
ResetPasswordFormPOST /api/auth/reset-password
Steps:
GET /api/auth/social/[provider]?returnTo=...
returnTo in cookie for callback/api/auth/callback?code=...GET /api/auth/callback
exchangeCodeForTokens)auth-token and auth-refresh-token)returnToNote: Social login is currently implemented and working. The flow uses the same session cookies as email/password authentication.
A PreSignUp_ExternalProvider Cognito trigger (the zooly-cognito-autolink Lambda) runs before step 4 above. If a native (email/password) account already exists for the same email, it links the Google identity into that account instead of letting Cognito create a second, unrelated user. See Identity Fragmentation for why this matters and a known first-attempt-retry quirk it introduces.
When a user registers or logs in with an email that was previously used for a guest checkout:
Process:
guest_emailuser_id using linkCognitoIdentitycognito_sub on existing identityuser_id)This enables:
Sessions are created when:
POST /api/auth/login)POST /api/auth/confirm)GET /api/auth/callback)Two cookies are set for each session:
auth-token (ID Token)
.zooly.ai (shared across all subdomains)true (not accessible to JavaScript)true (HTTPS only in production)Laxauth-refresh-token (Refresh Token)
.zooly.ai (shared across all subdomains)true (not accessible to JavaScript)true (HTTPS only in production)LaxImportant: Both cookies are HttpOnly and only accessible server-side. The refresh token is never exposed to client-side JavaScript.
When the ID token expires (after 24 hours), the system automatically refreshes it using the refresh token:
How it works:
/api/me (or any protected endpoint)auth-refresh-token cookieREFRESH_TOKEN_AUTH flowauth-token cookie in responseKey points:
Apps verify sessions by:
auth-token cookiesub, email) from token claimsJWT Verification:
aws-jwt-verify library with JWKSiss), audience (aud), expiration (exp)The verifyOrRefreshToken() helper function implements the refresh flow:
REFRESH_TOKEN_AUTH flowauth-token cookie with the new ID token in the responseThis ensures:
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.
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.
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():
{authUrl}/popup?origin={callerOrigin} in a ~480×720 window.postMessage from the popup, accepting only messages whose origin matches authUrl's origin and whose data.type is "zooly-auth:success"./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).{ 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.
/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.
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.*.zooly.ai anyway. A popup (a real top-level window) is the only workable no-redirect route for social login./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.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 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 (invalidating tokens server-side via Cognito) is not required for the current security posture but can be implemented if needed.
The refresh token implementation includes test endpoints for validation:
/api/test/refresh-flowReads 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:
idTokenPresent, refreshTokenPresent)valid, expired, invalid)refreshAttempted, refreshSucceeded, newTokenIssued)/api/test/force-refreshForces 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_AUTH flow)Note: To test automatic refresh without waiting 24 hours, temporarily reduce Cognito ID token validity using AWS CLI (see setup documentation for details).
Users stay signed in for up to ~3 months without manual re-authentication.
auth-token cookie with Max-Age of 90 daysauth-refresh-token cookie with Max-Age of 90 days/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 cookieResult: 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 data is split between two systems:
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.
On This Page
OverviewRedirect + returnTo FlowEntry FlowreturnTo ValidationPost-Login FlowExample FlowEmail/Password Authentication FlowSign Up FlowLogin FlowForgot Password FlowSocial Login Flow (Google/Apple)OAuth FlowGuest User LinkingSession ManagementSession CreationSession CookiesAutomatic Token RefreshSession VerificationToken Verification and Refresh LogicPopup Login (No-Redirect)FlowUsing itAuth app routesDesign CaveatsManual QA pageLogoutLocal LogoutGlobal Sign-OutTesting Refresh Token Flow[object Object][object Object]Long-Lived Sessions: 3 MonthsRequirementImplementationIdentity Storage