Skip to content

JWT Flow

Monozu Cloud uses a two-token JWT strategy: a short-lived access token held in browser memory and a long-lived refresh token stored in an HttpOnly cookie.

TokenStorageDefault TTLEnv var
Access tokenJavaScript memory (Zustand store)60 minutesACCESS_TOKEN_EXPIRE_MINUTES
Refresh tokenHttpOnly cookie (refresh_token)7 daysREFRESH_TOKEN_EXPIRE_DAYS

The access token is never stored in localStorage or sessionStorage — it lives only in the Zustand authStore and is lost on hard page refresh. The refresh token cookie handles rehydration.

sequenceDiagram
    participant Browser
    participant SPA as Cloud SPA
    participant API as Cloud Backend API
    participant DB as Azure SQL

    Browser->>SPA: Enter email + password
    SPA->>API: POST /api/v1/auth/login
    Note right of SPA: body: { email, password }
    API->>DB: SELECT user WHERE email = ? AND tenant active
    DB-->>API: User row
    API->>API: bcrypt.Compare(password, hash)
    alt MFA enabled
        API-->>SPA: 200 { mfa_required: true, session_token }
        SPA->>API: POST /api/v1/auth/mfa/verify
        Note right of SPA: body: { totp_code, session_token }
        API->>API: Verify TOTP
    end
    API->>API: Sign access JWT (HS256, 60 min)
    API->>API: Sign refresh JWT (HS256, 7 days)
    API-->>SPA: 200 { access_token } + Set-Cookie: refresh_token (HttpOnly)
    SPA->>SPA: Store access_token in authStore (memory)
    SPA-->>Browser: Redirect to dashboard

When the user reloads the page, the access token in memory is lost. The SessionRestore component runs before any protected route renders:

sequenceDiagram
    participant Browser
    participant SPA as Cloud SPA (SessionRestore)
    participant API as Cloud Backend API

    Browser->>SPA: Full page reload
    SPA->>SPA: authStore hydrated — user exists but access_token missing
    SPA->>API: POST /api/v1/auth/refresh (cookie sent automatically)
    alt Cookie valid + not expired
        API->>API: Validate refresh JWT
        API->>API: Issue new access JWT
        API-->>SPA: 200 { access_token }
        SPA->>SPA: Update authStore with new access_token
        SPA-->>Browser: Render protected page
    else Cookie missing or expired
        API-->>SPA: 401
        SPA-->>Browser: Redirect to /login
    end

The SPA refreshes the access token before it expires. TanStack Query’s background refetch and Axios interceptors handle 401 responses by attempting a refresh and retrying the original request.

sequenceDiagram
    participant SPA as Cloud SPA
    participant API as Cloud Backend API

    SPA->>API: POST /api/v1/auth/logout
    API->>API: Invalidate refresh token (DB blacklist or rotation)
    API-->>SPA: 200 + Set-Cookie: refresh_token= (cleared)
    SPA->>SPA: Clear authStore (access_token, user)
    SPA-->>SPA: Redirect to /login

When the SPA (cloud.monozu.io) and API (api.cloud.monozu.io) are on different origins, the refresh cookie must be sent cross-origin. Required backend configuration:

REFRESH_COOKIE_SAMESITE=none # Requires HTTPS
ALLOW_ORIGINS=https://cloud.monozu.io

The API must respond with Access-Control-Allow-Credentials: true and a specific (not wildcard) Access-Control-Allow-Origin.

The access token payload contains at minimum:

{
"sub": "<user_uuid>",
"tenant_id": "<tenant_uuid>",
"exp": 1234567890
}

The backend extracts tenant_id from the token to set SESSION_CONTEXT for RLS, and sub (user ID) for audit logging and RBAC resolution.