| name | handling-authentication-errors |
| description | Required patterns for auth and session errors in Fusebase Apps: AppTokenValidationError (401) on platform tokens, and backend session probes (/api/account/me) for SaaS apps with httpOnly cookies. Use when implementing auth error handling, AuthExpiredModal, session bootstrap on load, or global API error handlers. |
Handling Authentication Errors
Apps need two related but distinct patterns:
- Platform app token (
fbsfeaturetoken / x-app-feature-token) — AppTokenValidationError on Gate/Dashboard proxy calls.
- App-owned session (httpOnly cookie, e.g.
app_session) — backend /api/account/me (or equivalent) on load.
Do not treat every failed fetch as “logged out”. That lies to the user when the backend is restarting (deploy), the proxy returns 502, or the network blips.
Session probe invariant (backend SaaS apps)
Any SPA that boots auth from GET /api/account/me (or similar) MUST follow this table:
| Response on session check | Meaning | UI action |
|---|
401 | Session rejected by backend | Show login / anon state |
403 with known business code (membership_revoked, tenant_suspended, …) | Authenticated but blocked | Dedicated blocked screen |
| Everything else (502/503/504, 5xx, network error, timeout, aborted) | No verdict — server may be down | Retry (see below), then “Can't reach server” + Try again. Do not clear session cookie or force login |
Anti-pattern (never ship)
catch (e) {
if (e.code !== 'tenant_suspended' && e.code !== 'membership_revoked') {
setAuth({ status: 'anon' })
}
}
Required pattern
type SessionVerdict = 'authenticated' | 'anon' | 'blocked' | 'unknown'
async function probeSession(): Promise<SessionVerdict> {
const res = await fetch('/api/account/me', { credentials: 'include' })
if (res.status === 401) return 'anon'
if (res.status === 403) {
const body = await res.json().catch(() => ({}))
if (body.code === 'membership_revoked' || body.code === 'tenant_suspended') return 'blocked'
return 'unknown'
}
if (!res.ok) return 'unknown'
return 'authenticated'
}
async function probeSessionWithDeployTolerance(): Promise<SessionVerdict> {
const delays = [0, 400, 1200]
let last: SessionVerdict = 'unknown'
for (const ms of delays) {
if (ms) await new Promise((r) => setTimeout(r, ms))
try {
const v = await probeSession()
if (v !== 'unknown') return v
last = v
} catch {
last = 'unknown'
}
}
return last
}
On unknown: show a transient error surface (“Can't reach the server”, Retry). Keep the httpOnly session cookie untouched so the next successful probe restores the user without a false login screen.
SPA bootstrap checklist (backend apps):
- On mount:
probeSessionWithDeployTolerance() — not a single bare fetch.
unknown → loading/error UI with Retry; do not route to login.
anon → login/register routes.
authenticated → main app shell.
- Platform token expiry (
AuthTokenExpiredError) is a separate handler — do not merge with /api/account/me logic.
Why deploy tolerance matters
fusebase deploy restarts the app backend. Users who refresh during rollout may hit 502 from app-wrapper/proxy while the pod is not ready. Without retries, every deploy briefly “logs out” everyone refreshing in that window — even though app_session is still valid.
Platform app token: AppTokenValidationError
All apps MUST handle AppTokenValidationError responses from the platform API (Gate/Dashboard proxy). When the app token expires, the API returns a 401 with this body:
{
"name": "AppTokenValidationError",
"message": "Fail to validate app token",
"reason": "expired"
}
Implementation
Preflight: distinguish expired token vs wrong token context
Before changing UI error handling for Gate-powered features, first verify the current feature token against Gate:
const response = await fetch(
'https://app-api.{FUSEBASE_HOST}/v4/api/proxy/gate-service/v1/me',
{ headers: { 'x-app-feature-token': featureToken } }
)
Interpretation:
401 with AppTokenValidationError -> token is actually expired/invalid
200 but empty or unexpected permissions/scopes -> token context is wrong for the intended flow
- valid token + later
404 NotFound on store routes -> likely org/source-scope/store-discovery issue, not auth expiry
Do not show a Session Expired modal for plain NotFound or for valid-but-underprivileged tokens.
1. Detect AppTokenValidationError in API calls
The error name may appear at different nesting levels depending on the SDK. Check all of them:
function isAppTokenValidationError(error: unknown): boolean {
if (error && typeof error === 'object') {
const err = error as any
const names = [err.name, err.data?.name, err.error?.name, err.body?.name]
return names.includes('AppTokenValidationError')
}
return false
}
Create a custom AuthTokenExpiredError class. In every API call's catch block, check with the function above and throw AuthTokenExpiredError if matched; otherwise rethrow.
2. Show a "Session Expired" modal
When AuthTokenExpiredError is caught at the UI level, display a centered modal:
- Title: "Session Expired"
- Message: "Your authentication expired, please refresh the page to authenticate again."
- Buttons: "Refresh page" (calls
window.location.reload()) and "Cancel" (closes modal)
Manage modal open/close state in App.tsx and pass an onAuthError callback to child components that make API calls.
<% if (it.flags?.includes("portal-specific-apps")) { %>
Critical: /auth/context Behavior
The /auth/context endpoint MUST NOT trigger AuthTokenExpiredError just because user is missing.
When an app is public, anonymous visitors may receive an auth context with no user field. This is expected — it means "not authenticated", NOT "session expired". Throwing AuthTokenExpiredError here causes the Session Expired modal to appear immediately for every anonymous visitor.
type AuthContextResponse = {
user?: {
id: number
email: string
}
org?: {
globalId: string
}
runtimeContext?: {
portalId?: string
workspaceId?: string
}
}
export async function fetchAuthContext(
appToken: string
): Promise<AuthContextResponse> {
try {
const response = await fetch(
'https://app-api.{FUSEBASE_HOST}/v4/api/auth/context',
{ headers: { 'x-app-feature-token': appToken } }
)
if (!response.ok) return {}
return await response.json()
} catch {
return {}
}
}
Rule of thumb
/auth/context with missing user → treat as anonymous/guest
/auth/context request failure → handle gracefully without forcing "Session Expired"
- Dashboard/data API 401 with
AppTokenValidationError → throw AuthTokenExpiredError (session expired)
<% } else { %>
Critical: /users/me Exception
The /users/me endpoint MUST NOT trigger AuthTokenExpiredError.
When an app is public, anonymous visitors receive a 401 from /users/me. This is expected — it means "not authenticated", NOT "session expired". Throwing AuthTokenExpiredError here causes the Session Expired modal to appear immediately for every anonymous visitor.
export async function fetchCurrentUser(
appToken: string
): Promise<{ id: number; email: string } | null> {
try {
const response = await fetch(
'https://app-api.{FUSEBASE_HOST}/v4/api/users/me',
{ headers: { 'x-app-feature-token': appToken } }
)
if (!response.ok) return null
return await response.json()
} catch {
return null
}
}
Rule of thumb
/users/me 401 → return null (user is anonymous/guest)
- Dashboard/data API 401 with
AppTokenValidationError → throw AuthTokenExpiredError (session expired)
<% } %>
Quick reference
| Layer | Endpoint | 401 means | 5xx / network means |
|---|
| Platform token | Gate/Dashboard via app-api proxy | Token expired → refresh page modal | Transient — retry, not logout |
| App session cookie | /api/account/me (your backend) | Not logged in → login screen | Transient — retry + “server unavailable”, keep cookie |
Same-origin /api/* and the platform proxy
Feature token source (cookie, not embed)
For same-origin fetch('/api/…'), the browser sends the fbsfeaturetoken cookie automatically. The platform rotates this cookie; an embedded window.FBS_FEATURE_TOKEN from the initial HTML does not.
- Read order: cookie
fbsfeaturetoken first; treat window.FBS_FEATURE_TOKEN as bootstrap-only (first paint), never as the live token after navigation.
- Do not send
x-app-feature-token on same-origin /api/* unless you have a deliberate cross-origin reason. The scaffold pattern is bare fetch('/api/items', { credentials: 'include' }) — the proxy authenticates from the cookie.
- Do not retry opaque redirects or 302 chains on mutations — a stale token will not heal on retry.
Proxy returns 302 on API requests (not 401)
Today the app-wrapper may answer invalid/expired session on /api/* with 302 Location: …/auth/?appSuccess=… instead of 401 JSON. For fetch() this is catastrophic:
- Default
fetch follows the redirect → cross-origin CORS → TypeError: NetworkError with no status, no body
- The app cannot distinguish “token dead” from “server down”
Workaround until platform fix (NIM-42325):
const res = await fetch('/api/tenant/invites', {
method: 'POST',
credentials: 'include',
redirect: 'manual',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload),
})
if (res.type === 'opaqueredirect' || res.status === 302) {
const verdict = await probeSessionWithDeployTolerance()
if (verdict === 'anon') showAuthExpiredModal()
else showTransientError()
}
redirect: 'manual' is self-defense, not approval of the design. After NIM-42325 lands, keep handling 401 explicitly; opaqueredirect should become rare on /api/*.
Proxy slowdown windows
Under load, the proxy may return a redirect after it already forwarded the request to your backend. Symptom: mutation “failed” in the UI but side effect happened; or GET /me flips to anon for a few seconds with a valid cookie.
- On
opaqueredirect / ambiguous auth failure: poll probeSessionWithDeployTolerance() (~7s total), do not clear cookies on the first miss.
- On failed mutations: reconcile with server state (list/read) before offering retry — the write may have succeeded.