Auth and the BFF
@navree/bff: httpOnly token cookies, single-flight refresh, two-factor sign-in, the Origin check, a streaming proxy, sessions for server rendering, and test helpers.
On this page
A token in localStorage is one cross-site scripting bug away from leaking every session. @navree/bff keeps the tokens on the server: it is the backend-for-frontend of a web app on a Navree API, written on web-standard Request and Response. @navree/next mounts it as route handlers and @navree/nuxt as Nitro routes; any server that speaks web requests can mount it too.
Creating the BFF
import { createBff, bffOptionsFromEnv } from "@navree/bff";
export const bff = createBff(bffOptionsFromEnv());
// In a route that receives every /api/* and /broadcasting/auth request:
export const handle = (request: Request) => bff.handle(request);Create one BFF per server process and share it: the refresh map that keeps refreshes single-flight lives in it. @navree/next and @navree/nuxt create theirs on the first request.
bff.handle(request) routes by path:
| Request | Handler | What it does |
|---|---|---|
POST /api/auth/login | login | Signs in at the API, sets the token cookies, answers {user}; a two-factor account answers {two_factor_required: true, expires_in}. |
POST /api/auth/2fa/verify | verifyTwoFactor | Sends {code} or {recovery_code} with the challenge token from its cookie, then sets the token cookies like login. |
POST /api/auth/logout | logout | Revokes the refresh token at the API ({all: true} ends every session), clears the cookies, answers 204. |
POST /api/auth/refresh | refreshSession | Rotates the cookies (204), or clears them (401 session_expired). |
| Anything else | proxy | Forwards to the API at the same path with the bearer token: /api/*, /broadcasting/auth, a server-rendered tool mounted at /admin. |
What the BFF guarantees
Cookies. The access token is in navree_at (Max-Age = the token's expires_in), the refresh token in navree_rt (30 days), both HttpOnly; SameSite=Lax; Path=/, and Secure in production. A pending two-factor challenge waits in navree_2fa on Path=/api/auth/2fa, so only the code step receives it. A token pair in any auth response (a password change answers one) replaces the cookies and is stripped from the body.
Refresh. When the access cookie is gone but the refresh cookie is left, the proxy refreshes before forwarding; on a 401 it refreshes once and retries. Navree rotates refresh tokens and revokes the whole family when one is spent twice, so the refresh is single-flight per refresh token: concurrent requests holding the same cookie share one refresh call, and a finished refresh is remembered for 30 seconds for requests still carrying the old cookie. A failed refresh clears both cookies and the response is the 401, so the browser goes to sign-in.
CSRF. A non-GET request must come from the app's own origin: its Origin header's host must be the host the browser addressed (X-Forwarded-Host, else Host), or be listed in allowedOrigins. Anything else gets 403 invalid_origin before the API is called.
Pass-through. Status and body stream through unchanged, so CSV exports and server-sent events arrive as the API sends them. The response headers that pass are cache-control, content-disposition, content-language, content-security-policy, content-type, etag, last-modified, location, referrer-policy, retry-after, vary, x-content-type-options, x-frame-options, x-request-id and every x-ratelimit-*; the API's own Set-Cookie never reaches the browser. Upstream requests use cache: "no-store".
Request headers. accept, accept-language, content-type, if-match, if-none-match, if-modified-since, last-event-id, user-agent and x-request-id are forwarded, plus the workspace: the request's X-Workspace header, or the navree_workspace cookie when there is none. Cookies never leave the BFF.
Client address. The browser's own X-Forwarded-For and X-Real-IP never reach the API. Behind trustedProxyHops reverse proxies that append the client to X-Forwarded-For, the BFF sends the API a single X-Forwarded-For with the address the outermost one saw. List the BFF's address in the API's http.trusted_proxies so the login throttle and per-IP rate limits apply per browser.
The BFF's own errors
The BFF answers some requests itself, in Navree's error shape:
| Status | code | When |
|---|---|---|
| 401 | session_expired | POST /api/auth/refresh without a refresh token, or the API refused it |
| 401 | two_factor_expired | POST /api/auth/2fa/verify without the challenge cookie |
| 403 | invalid_origin | A non-GET request from another origin |
| 502 | api_unreachable | The API didn't answer |
| 502 | unexpected_response | The API's sign-in answer had no token pair |
Options
| Option | Default | Meaning |
|---|---|---|
apiUrl | (required) | The API, e.g. http://127.0.0.1:8080. |
secureCookies | on when NODE_ENV is production | The Secure flag. |
allowedOrigins | none | Extra origins allowed to send non-GET requests (the public URL behind a proxy that rewrites Host). |
trustedProxyHops | 0 | Reverse proxies in front of the app that append the client to X-Forwarded-For. |
refreshMaxAge | 30 days | The refresh cookie's lifetime, in seconds. |
refreshGraceMs | 30000 | How long a finished refresh is remembered. |
refreshSkewSeconds | 30 | ensureSession refreshes an access token this close to its expiry. |
authPrefix | /api/auth | The API's auth routes. |
mePath | /api/auth/me | The current-user route me() calls. |
workspaceHeader | X-Workspace | The API's workspace.header. |
cookieNames | navree_at, navree_rt, navree_2fa, navree_workspace | {access, refresh, challenge, workspace}, for several apps on one domain. |
upstreamPath(pathname) | the same path | Maps a request path to the API path handle forwards to. |
fetch | globalThis.fetch | The fetch for upstream calls (tests). |
bffOptionsFromEnv(env?, defaults?) reads them from the environment at request time, so the API can move without a rebuild:
| Variable | Meaning |
|---|---|
NAVREE_API_URL (or NUXT_NAVREE_API_URL) | The API (default http://127.0.0.1:8080). |
NAVREE_COOKIE_SECURE | 1/true or 0/false: the Secure flag. Set 0 to serve a production build over plain http. |
APP_ORIGINS | Extra allowed origins, comma-separated. |
NAVREE_TRUSTED_PROXY_HOPS | Reverse proxies that append the client to X-Forwarded-For. |
NAVREE_WORKSPACE_HEADER | The API's workspace.header. |
Sessions on the server
Server rendering, server routes and middleware need a usable token for the request they handle. bff.ensureSession(request) gives one:
const session = await bff.ensureSession(request);
// { status: "active" | "refreshed" | "anonymous" | "expired", accessToken?, setCookie: string[], cookieHeader? }status | Meaning | What to do |
|---|---|---|
anonymous | No token cookies. | Send the browser to sign-in. |
active | The access token is still valid (or opaque, so its expiry can't be read). | Use accessToken. |
refreshed | The access token had expired (or was within refreshSkewSeconds), and the refresh worked. | Add setCookie to the response, use cookieHeader for the rest of this request, use accessToken. |
expired | The refresh failed. | Add setCookie (it clears the cookies) and send the browser to sign-in. |
ensureSession reads the JWT's exp without verifying the signature, only to decide whether to refresh; the API verifies every token it receives. It shares the refresh map with the proxy, so a page and its first API call never spend the same refresh token twice. The Next.js proxy and the Nuxt session gate call it for every page.
With the token, call the API from the server:
const user = await bff.me(session.accessToken, bff.workspaceOf(request)); // null when signed out
const response = await bff.api("/api/v1/deals?page=2", { accessToken: session.accessToken, workspace: "acme" });me() answers null for a missing or refused token and throws when the API is down, so a page can tell "signed out" from "unreachable".
Testing
@navree/bff/testing has the helpers the package's own tests use:
import { createBff } from "@navree/bff";
import { browserRequest, fakeNavree, readSetCookies, tokenPair } from "@navree/bff/testing";
import { expect, it } from "vitest";
it("signs in with httpOnly cookies", async () => {
const navree = fakeNavree({
"POST /api/auth/login": () => Response.json({ user: { id: "u1" }, ...tokenPair(1) }),
});
const bff = createBff({ apiUrl: "http://api.test", fetch: navree.fetch });
const response = await bff.handle(
browserRequest("http://app.test", "/api/auth/login", { method: "POST", body: { email: "[email protected]", password: "pw" } }),
);
expect(await response.json()).toEqual({ user: { id: "u1" } });
expect(readSetCookies(response).navree_at.attrs).toContain("HttpOnly");
expect(navree.calls[0].headers.get("authorization")).toBeNull();
});| Helper | What it is |
|---|---|
fakeNavree(routes) | A scripted API keyed by "METHOD /path"; records every call in calls and answers 404 in Navree's shape otherwise. |
browserRequest(appUrl, path, {method, cookies, origin, body, headers}) | A request as a browser sends it, with the app's own Origin on non-GET requests (origin: null sends none). |
readSetCookies(response) | The Set-Cookie headers by name: {value, attrs}. |
tokenPair(n) | Navree's token response with AT{n} and RT{n}. |
unsignedJwt(claims) | A JWT the API would never accept, for testing expiry handling. |