Authentication & Routing
Numa’s canonical guide for authentication, product platforms, JWT, proxy.ts, and [lang] routes.
Conceptual overview
The app uses three cooperating signals to determine user identity and platform access:
| Signal | Source | Used for |
|---|---|---|
| JWT (access token) | Cookie VAR_ACCESS_TOKEN, decoded in proxy | Route gating before /me runs |
GET /me | API, called from RSC and/or React Query | Authoritative profile; drives cookie sync |
Cookie VAR_PERMITTED_PLATFORMS | JSON array, set client-side after /me | When JWT has no derivable platforms, proxy still knows |
Platforms
Defined in @numa/config/platforms:
| Platform | Permission string | Route prefix |
|---|---|---|
ENERGY | ENERGY_APP | /energy |
OPERATIONS | OPERATIONS_APP | /operations |
FINANCE | FINANCE_APP | /finance |
Access is determined by exact match: a user can open /energy only if their permissions array contains ENERGY_APP.
Cookies
| Cookie | Purpose |
|---|---|
VAR_ACCESS_TOKEN | JWT access token (sent as Bearer on API calls) |
VAR_REFRESH_TOKEN | Refresh token (used by Axios interceptor) |
VAR_PERMITTED_PLATFORMS | JSON TPlatform[], updated after every /me |
VAR_SELECTED_LOCALE | Locale preference |
Why a permissions cookie?
proxy.ts runs on the edge and must decide whether a path is allowed before React renders. Calling /me from the edge on every request would add latency. The JWT alone often doesn’t list product permissions the same way /me does. So the app keeps a mirror: whatever /me last returned, derived to TPlatform[], stored in VAR_PERMITTED_PLATFORMS.
JWT decoding
File: utils/funcs/get-user-from-token.ts
platforms— derived frompermissions/Permissionsclaims usingderivePlatformsFromMeprimaryRole— optional explicit claim, else first permitted platform inPLATFORMSorder
Merge order: JWT → cookie → empty
getUserFromToken(token).platforms(from JWT permissions)- If empty, parse
VAR_PERMITTED_PLATFORMScookie - If still empty, return
[]→ user goes to shared profile, not a product dashboard
proxy.ts (edge routing)
Each app’s proxy.ts handles:
- Locale — paths without locale prefix are normalized
- Unauthenticated + private route → redirect to login
- Authenticated + public route → redirect to default home
- Authenticated + platform route → if user not allowed, redirect to default home
- Headers — sets
x-pathnameon forwarded request
Post-login redirect flow
After login, the cookie may not yet reflect /me permissions. Auth flows call resolvePostLoginPath(accessToken):
- Fetches
/mewith explicitAuthorization: Bearerheader - Writes
VAR_PERMITTED_PLATFORMSfromderivePlatformsFromMe - Returns the correct home path (or shared profile)
- Login hooks
awaitthis, thenrouter.pushto/{locale}${path}
Private app shell
Server (fetchUserEssentials):
- Calls
GET /mewith Bearer token from cookie - If successful, wraps children in
EssentialsProvider - If token exists but fetch fails, falls back to
HydrateEssentialsFromClient
Client (useAppEssentials):
- Runs
useGetMeQueryunless already provided viaEssentialsProvider - On every
/meupdate, syncs theVAR_PERMITTED_PLATFORMScookie
Cross-app authentication
All apps share cookies under the same parent domain (e.g., .numalogistics.com). When a user logs in:
- Auth tokens stored as cookies on the shared domain
- Auth app redirects to the user’s permitted platform
- Platform apps read auth cookies to verify access
OIDC multi-subdomain
When NEXT_PUBLIC_AUTH_COOKIE_DOMAIN is set:
- Shared auth cookies become the cross-subdomain source of truth for OIDC user state
- Stale per-tab OIDC state is cleared when shared cookies disappear
- Periodic re-checks on focus/visibility
Environment
| Variable | Purpose |
|---|---|
NEXT_PUBLIC_API_BASE_URL | Base URL for API calls and server fetch |
NEXT_PUBLIC_AUTH_COOKIE_DOMAIN | Parent domain for shared auth cookies (omit on localhost) |
NEXT_PUBLIC_GATEWAY_APP_URL | Gateway app URL for cross-app redirects |