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_REFRESHEDevent viaonAuthStateChange. - 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.