Skip to content

Auth Flow

How App-name authenticates users and enforces access. Credentials are exchanged once via the Supabase client, but every subsequent data access is proxied through the backend, which verifies the JWT and resolves the user's role server-side. The frontend never trusts itself.

The strict pattern

The application uses a strict backend-frontend architecture:

  • The initial credential exchange happens through the Supabase client SDK (Google + Microsoft OAuth, email/password, TOTP MFA).
  • After that, profile hydration and all data access go through the backend API using the JWT access token.
  • The backend always verifies the token with Supabase and reads the user's role from PostgreSQL — roles are never taken from the client.

Roles are server-side facts

A client can claim any role it likes. The backend ignores client claims and fetches the role from the profiles table keyed on the verified user.id. Role-based access (e.g. admin) is therefore unspoofable.

The architecture linter (scripts/check-architecture.js) enforces this by blocking direct Supabase/DB calls from the frontend — see Agent Workflows.

Login → verification → hydration

sequenceDiagram
    participant User
    participant Client as Frontend (AppContext)
    participant SBAuth as Supabase Auth
    participant API as Backend API
    participant DB as PostgreSQL (profiles)

    Note over User, Client: One-time credential exchange
    User->>Client: Enters credentials / OAuth
    Client->>SBAuth: supabase.auth.signInWithPassword() / OAuth
    SBAuth-->>Client: Session (JWT access_token + refresh_token)

    Note over Client, DB: Hydration & verification (strict proxy)
    Client->>Client: onAuthStateChange(SIGNED_IN)
    Client->>API: GET /api/me (Authorization: Bearer <token>)

    activate API
    Note right of API: verifyAuth middleware
    API->>SBAuth: supabase.auth.getUser(token)
    SBAuth-->>API: Valid user { id, email }
    API->>DB: SELECT * FROM profiles WHERE id = user.id
    DB-->>API: dbUser { role, currentTeamId, ... }
    API-->>Client: Combined user profile
    deactivate API

    Client->>Client: setUser({ ...session, ...dbUser })
    Client->>User: Grants access to the dashboard

Step by step

Step Where What happens
1. Credential exchange Frontend supabase-js performs the OAuth/password handshake; the backend never sees raw passwords.
2. Session established Frontend Supabase returns a Session with an access_token (JWT) and a refresh_token; onAuthStateChange fires.
3. Profile hydration Frontend → Backend The frontend calls GET /api/me with Authorization: Bearer <token> instead of querying the DB itself.
4. Verification Backend (verifyAuth) The middleware calls supabase.auth.getUser(token) to confirm the JWT is genuine and unexpired, then loads the role from PostgreSQL.
5. Completion Frontend The combined user + profile is stored in global context and the dashboard unlocks.

OAuth providers, MFA, and JWT settings are configured in the Supabase project — see Supabase (Online).

Token refresh strategy

Access tokens are short-lived; refresh tokens are long-lived. The Supabase client SDK handles renewal automatically.

  • The SDK refreshes the access token in the background before it expires and emits a TOKEN_REFRESHED event via onAuthStateChange.
  • The frontend keeps using the current access token on every backend call, so refreshed tokens flow through transparently.
  • The backend stays stateless — it re-verifies whatever bearer token arrives on each request. There is no server-side session to keep in sync.

Always send the live token

Read the token from the active session at call time rather than caching it at login. After a refresh, a cached token will be stale and rejected as expired.

Logout and session cleanup

sequenceDiagram
    participant User
    participant Client as Frontend
    participant SBAuth as Supabase Auth

    User->>Client: Clicks "Sign out"
    Client->>SBAuth: supabase.auth.signOut()
    SBAuth-->>Client: Session revoked
    Client->>Client: onAuthStateChange(SIGNED_OUT)
    Client->>Client: Clear user/team context, redirect to login

On sign-out the SDK revokes the refresh token and clears the stored session. The frontend listens for SIGNED_OUT, wipes its global context (user, team, cached data), and redirects to the login page. Because the backend is stateless, no server-side cleanup is required — any in-flight token simply stops being presented.

Error cases

Case Where detected Response Frontend handling
Expired token Backend verifyAuth 401 Unauthorized The SDK normally refreshes first; if a 401 still arrives, force a session refresh or sign the user out and redirect to login.
Invalid / forged token Backend verifyAuth 401 Unauthorized Treat as unauthenticated — clear context and redirect to login. Never retry with the same token.
No token Backend verifyAuth 401 Unauthorized Protected routes require a bearer token; the frontend redirects to login.
Valid token, missing profile Backend (DB lookup) 403 Forbidden / 404 User exists in Auth but has no profiles row — surface an "account not provisioned" message rather than granting partial access.
Insufficient role Backend (role check) 403 Forbidden Hide or disable the action in the UI, but rely on the backend's 403 as the real boundary.

Never authorize on the client

Hiding a button is UX, not security. Every privileged action must be re-checked by the backend against the server-resolved role. The frontend only reflects what the backend already enforces.