Skip to main content

How Auth Works

Token pair​

Authentication uses two tokens:

TokenStorageLifetimePurpose
Access token (JWT)Memory (JS variable)Short (e.g. 15 min)Sent as Authorization: Bearer <token> on every API request
Refresh tokenhttpOnly cookieLong (e.g. 7 days)Used to obtain a new access token without re-logging in

Storing the access token in memory (not localStorage) protects against XSS. The httpOnly cookie is inaccessible to JavaScript, protecting against token theft.

Login flow​

1. User submits email + password
2. POST /api/auth/login
3. Backend validates credentials, issues access token + sets refresh cookie
4. Frontend stores access token in AuthService (memory)
5. All subsequent API calls go through apiFetch(), which attaches the Bearer token

Token refresh flow​

1. apiFetch() gets a 401 response
2. AuthService automatically calls POST /api/auth/refresh
3. Browser sends the httpOnly refresh cookie automatically
4. Backend validates refresh token, issues new access token + rotates refresh cookie
5. Original request is retried with the new token

Logout​

POST /api/auth/logout

Clears the refresh cookie on the backend and wipes the in-memory access token on the frontend.

Permissions in the JWT​

The access token payload includes the user's resolved permissions (role permissions merged with individual overrides). This allows the frontend to gate UI without an extra API call, and allows the backend middleware to validate scopes synchronously without a database lookup.

{
"sub": "user-uuid",
"role": "admin",
"permissions": [
"scene:edit",
"scene_block:edit",
"scene_block:frame:generate",
"..."
]
}

The apiFetch utility​

All HTTP calls in the frontend go through apiFetch (fe-create-lab-studio/src/utils/apiFetch.ts):

export async function apiFetch(
url: string,
init: RequestInit = {}
): Promise<Response> {
const token = authService.getAccessToken();
return fetch(url, {
...init,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
'Accept-Language': i18n.language || 'en',
...((init.headers as Record<string, string>) ?? {}),
},
});
}

:::warning Audio playback new Audio(url) does not send custom headers. Any URL served by the authenticated API (voiceovers, sound effects) must be fetched via apiFetch → blob → URL.createObjectURL() before being passed to an Audio element. :::