Zooly Auth is built on a centralized authentication model where all apps share a single identity provider.
AutoLink prevents duplicate Cognito accounts when the same email is used for both Google and native sign-in — see Identity Fragmentation for the full mechanism and the cleanup process for accounts that fragmented before it was deployed.
The authentication system is organized into three main packages:
@zooly/auth-dbDynamoDB access layer for identity storage:
getIdentity(userId) - Get identity by user IDupsertIdentity(identity) - Create or update identityupdateRoles(userId, roles) - Update user roles. Guarded with a DynamoDB attribute_exists(user_id) condition — it can only modify an existing identity, never create one. Callers must resolve the real identity first (see Identity Fragmentation)findIdentityByEmail(email) - Find identity by emailgetOrCreateByEmail(email, name?) - Get or create guest identitylinkCognitoIdentity(userId, cognitoSub) - Link Cognito user to existing identitygetCognitoUserBySub(sub) (in @zooly/auth-srv) - Recover a user's verified email from Cognito when only the sub is known@zooly/auth-srvServer-side authentication functions:
signIn(email, password) - Authenticate usersignUp(email, password, displayName?) - Register new userconfirmSignUp(email, code) - Verify email with coderesendConfirmationCode(email) - Resend verification codeforgotPassword(email) - Initiate password resetconfirmForgotPassword(email, code, newPassword) - Complete password resetaws-jwt-verify (ID tokens)refreshTokens) using REFRESH_TOKEN_AUTH flowverifyOrRefreshToken) that refreshes expired tokens automatically@zooly/auth-clientReact components and utilities for authentication UI:
LoginPage - Main authentication page componentLoginForm, PasswordForm, SignUpForm, ConfirmSignUpForm, ForgotPasswordForm, ResetPasswordFormAuthContextProvider and useAuth hook for user state managementvalidateReturnTo utility for safe redirect handlingIdentity storage is split between Cognito and DynamoDB:
Cognito
└─ sub (identity anchor)
└─ email (source of truth)
Identity Service
└─ DynamoDB (sub → profile data)
Apps
└─ call /api/me
├─ verify ID token JWT
├─ extract email from ID token (Cognito source of truth)
├─ read profile from DynamoDB using sub
└─ return unified profile (email from Cognito + profile from DynamoDB)
sub: Stable user ID (identity anchor)email: Source of truth for user emailTable: zooly-auth-identities
Primary Key: user_id (string) - Cognito sub for linked users, generated for email-only users
Item Structure:
{
"user_id": "sub or generated",
"guest_email": "guest@example.com",
"display_name": "Elia",
"avatar_url": "https://...",
"roles": ["admin"],
"cognito_sub": "sub",
"created_at": 1700000000,
"updated_at": 1700500000
}
Important: Email is NOT stored in DynamoDB. For Cognito-linked users, email comes from Cognito ID token (source of truth). Only guest_email is stored for guest checkout users before linking.
.zooly.ai (shared across all subdomains)Secure alwaysHttpOnly where possibleSameSite=Lax by defaultauth-token): Stores Cognito ID token in HttpOnly cookieauth-refresh-token): Stores Cognito refresh token in HttpOnly cookieBackends derive identity from:
JWT Claims (from Cognito ID token):
sub - Stable user ID (used as DynamoDB key)email - From Cognito ID token (source of truth, not stored in DynamoDB)DynamoDB Profile (from identities table):
display_nameavatar_urlroles (string[] - stored in DynamoDB)guest_email (only for guest checkout users before linking)Roles are stored in DynamoDB and retrieved when looking up the identity:
roles: string[] in the identities tableroles: [] (empty array)PATCH /api/admin/users/:id/roles), which resolves the user's real identity record (sub, then email fallback) before writing — never a manual blind UpdateItem by sub, which historically could create a duplicate stub record for guest-origin users (see Identity Fragmentation)/api/me response from DynamoDB, not from Cognito tokenExample roles:
["admin"] => admin role["editor"] => editor role[] => default userBackends validate JWT signatures using JWKS (AWS verifier library):
iss)exp)sub)No shared secret is used; keys are fetched and cached via JWKS.
When an ID token expires, the system automatically refreshes it:
REFRESH_TOKEN_AUTH flow with refresh tokenauth-token cookie with new ID token in responseKey Points: