Architecture

Architecture overview of Zooly authentication system

System Architecture

Zooly Auth is built on a centralized authentication model where all apps share a single identity provider.

flowchart TB subgraph client [Client Browser] LoginPage[Login Page] AuthContext[Auth Context] end subgraph guestClient [Guest Checkout Client] GuestCheckout[Guest Checkout] end subgraph authApp [auth.zooly.ai] LoginAPI["/api/auth/login"] MeAPI["/api/me"] GuestGetOrCreateAPI["/api/users/get-or-create"] LogoutAPI["/api/auth/logout"] CallbackAPI["/api/auth/callback"] end subgraph aws [AWS] Cognito[Cognito User Pool] DynamoDB[DynamoDB identities] end subgraph packages [Packages] AuthClient["@zooly/auth-client"] AuthSrv["@zooly/auth-srv"] AuthDB["@zooly/auth-db"] end LoginPage --> LoginAPI GuestCheckout --> GuestGetOrCreateAPI LoginAPI --> Cognito LoginAPI --> AuthDB GuestGetOrCreateAPI --> AuthDB AuthDB --> DynamoDB MeAPI --> AuthSrv AuthSrv --> Cognito AuthSrv --> AuthDB

Package Structure

The authentication system is organized into three main packages:

@zooly/auth-db

DynamoDB access layer for identity storage:

  • getIdentity(userId) - Get identity by user ID
  • upsertIdentity(identity) - Create or update identity
  • updateRoles(userId, roles) - Update user roles
  • findIdentityByEmail(email) - Find identity by email
  • getOrCreateByEmail(email, name?) - Get or create guest identity
  • linkCognitoIdentity(userId, cognitoSub) - Link Cognito user to existing identity

@zooly/auth-srv

Server-side authentication functions:

  • signIn(email, password) - Authenticate user
  • signUp(email, password, displayName?) - Register new user
  • confirmSignUp(email, code) - Verify email with code
  • resendConfirmationCode(email) - Resend verification code
  • forgotPassword(email) - Initiate password reset
  • confirmForgotPassword(email, code, newPassword) - Complete password reset
  • JWT verification using aws-jwt-verify (ID tokens)
  • Token refresh function (refreshTokens) using REFRESH_TOKEN_AUTH flow
  • Auto-refresh helper (verifyOrRefreshToken) that refreshes expired tokens automatically
  • Session/cookie helpers for ID token and refresh token storage

@zooly/auth-client

React components and utilities for authentication UI:

  • LoginPage - Main authentication page component
  • Form components: LoginForm, PasswordForm, SignUpForm, ConfirmSignUpForm, ForgotPasswordForm, ResetPasswordForm
  • AuthContextProvider and useAuth hook for user state management
  • validateReturnTo utility for safe redirect handling

Identity Storage Architecture

Identity 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)

Cognito Storage

  • sub: Stable user ID (identity anchor)
  • email: Source of truth for user email

DynamoDB Storage

Table: 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.

Session Management

  • Domain: .zooly.ai (shared across all subdomains)
  • Security Flags:
    • Secure always
    • HttpOnly where possible
    • SameSite=Lax by default

Token Strategy

  • ID Token Cookie (auth-token): Stores Cognito ID token in HttpOnly cookie
  • Refresh Token Cookie (auth-refresh-token): Stores Cognito refresh token in HttpOnly cookie
  • Backend Validation: Backends read and validate JWT directly using JWKS
  • Cookie Expiry: 90 days for both cookies
  • ID Token Validity: 24 hours (Cognito maximum)
  • Refresh Token Validity: 90 days
  • Automatic Refresh: When ID token expires, server automatically refreshes using refresh token (transparent to user)

Authorization Model

Identity Derivation

Backends 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_name
  • avatar_url
  • roles (string[] - stored in DynamoDB)
  • guest_email (only for guest checkout users before linking)

Roles

Roles are stored in DynamoDB and retrieved when looking up the identity:

  • Roles are stored as roles: string[] in the identities table
  • Default users have roles: [] (empty array)
  • Roles are managed via admin API or manual DynamoDB updates
  • Roles are returned in /api/me response from DynamoDB, not from Cognito token

Example roles:

  • ["admin"] => admin role
  • ["editor"] => editor role
  • [] => default user

Backend JWT Verification

Backends validate JWT signatures using JWKS (AWS verifier library):

  • Verify issuer (iss)
  • Verify token use (ID token - contains email from Cognito)
  • Verify expiration (exp)
  • Verify audience/client ID as appropriate
  • Authorize using roles from DynamoDB (looked up via sub)

No shared secret is used; keys are fetched and cached via JWKS.

Token Refresh Flow

When an ID token expires, the system automatically refreshes it:

  1. Token Verification: Backend attempts to verify ID token using JWKS
  2. Expiration Detection: If token is expired, system reads refresh token from cookie
  3. Refresh Request: Calls Cognito REFRESH_TOKEN_AUTH flow with refresh token
  4. New Token: Receives new ID token and Access token from Cognito
  5. Verification: Verifies new ID token using JWKS
  6. Cookie Update: Updates auth-token cookie with new ID token in response

Key Points:

  • Refresh happens transparently on the server (no redirects)
  • Only expired tokens trigger refresh (invalid/malformed tokens return 401 immediately)
  • Users stay logged in for the full 90-day refresh token lifetime
  • Refresh token remains valid for its full lifetime (not rotated)