navree
Documentation

Backend for frontend

A web front end that never sees a token: a backend-for-frontend, in Next.js or Nuxt, holds the JWTs in httpOnly cookies, rotates them and calls the Navree API on the browser's behalf.

On this page

Introduction

A token stored in localStorage can be read by any script on the page, so one cross-site scripting bug leaks every session. A backend-for-frontend (BFF) removes the token from the browser altogether. The browser talks only to its own web server, on the same origin. That server keeps the access and refresh tokens in httpOnly cookies, which no script can read, and calls the Navree API with Authorization: Bearer.

The API doesn't change. It stays a plain bearer-token API that mobile apps, partners, other services and the BFF all call the same way. The BFF is the web app's own small backend: it signs in, forwards requests, rotates tokens and enforces the same-origin rule that protects the cookies.

Backend for frontendThe browser calls only its own web server, on the same origin, and holds cookies it can't read. The Next.js BFF keeps the access token (navree_at) and the refresh token (navree_rt) in httpOnly cookies and calls the Navree API with Authorization: Bearer. When the access token expires it refreshes once per refresh token, and Navree rotates the refresh token on every use. The Navree API reads and writes PostgreSQL in one transaction per request, and after a change it broadcasts to its WebSocket server, which pushes the event straight to the browser. The socket carries no token; private channels are authorized through the BFF.Browsercookies it can't readNext.js BFFhttpOnly cookiesnavree_ataccessnavree_rtrefreshNavree APIHTTP and WebSocketPostgreSQLusers · token familiessame originBearerrefresh,rotatesone transaction
Backend for frontend
  • request
  • real-time event

The reference CRM has two front ends, each with a BFF that keeps the same contract: apps/crm-web, a Next.js app with its BFF in src/lib/bff/, and apps/crm-nuxt, a Nuxt app with its BFF in server/lib/bff.ts. navree new --ui next generates a smaller version of the Next.js one, and the SaaS starter kit ships the full one for either framework. The code on this page comes from the CRM; where the two differ, it shows both.

The shape

PartyHoldsTalks to
BrowserCookies it can't read, the user resource, the access rules (GET /api/me/abilities)The BFF, same origin; the WebSocket server for real time
BFF (the Next.js or Nuxt server)The access token, the refresh token, a pending two-factor challengeThe Navree API, with Authorization: Bearer
Navree APIUsers, refresh-token families, everything elseNobody: it answers

The browser calls same-origin paths: /api/auth/login, /api/v1/deals, /broadcasting/auth. Route handlers in the Next.js app, or server routes in the Nuxt app, forward them to the API at NAVREE_API_URL with the same path, so the browser's API client and the API's OpenAPI document agree on every path.

One BFF, two frameworks

The BFF itself is framework-free TypeScript: createBff takes the API's URL and a few options and returns functions that take a web Request and return a Response. Next.js route handlers already speak Request and Response; in Nuxt, Nitro's toWebRequest(event) turns an event into one and a handler may return a Response. So each framework adds only a thin layer: one instance of the BFF and a one-line route per path. The auth routes cover sign-in, the two-factor step, refresh and sign-out; the catch-all forwards every other /api/* call.

Next.js (apps/crm-web)Nuxt (apps/crm-nuxt)
The BFFsrc/lib/bff/bff.tsserver/lib/bff.ts
Its instancesrc/lib/bff/server.ts, created when the module loadsserver/lib/instance.ts, useBff(), created on first use from the runtime config
Auth routesRoute handlers in src/app/api/auth/Server routes in server/api/auth/ (login.post.ts, 2fa/verify.post.ts, refresh.post.ts, logout.post.ts)
Catch-allsrc/app/api/[...path]/route.tsserver/api/[...path].ts
Channelssrc/app/broadcasting/auth/route.tsserver/routes/broadcasting/auth.post.ts

Either instance holds the single-flight refresh map (below), so it is shared by every request the server handles. The Nuxt app also has a server middleware, server/middleware/session-gate.ts, that sends a page request with neither token cookie to /login?next=... before the app loads; whether a cookie is still valid stays the API's call.

Signing in

The browser posts the email and password to the BFF's /api/auth/login. The BFF calls POST /api/auth/login on Navree, keeps both tokens in cookies and returns only the user:

apps/crm-web/src/lib/bff/bff.ts

A failed login passes Navree's answer through unchanged (a 401 "Invalid credentials", or a 429 from the login throttle), so the form shows the API's message. The route that calls it is a few lines in either framework:

apps/crm-web/src/app/api/auth/login/route.ts

The BFF sets three cookies, all HttpOnly; SameSite=Lax, and Secure in production:

CookieHoldsLifetimePath
navree_atThe JWT access tokenexpires_in from the API (auth.jwt.access_ttl_secs, 15 minutes by default)/
navree_rtThe refresh token30 days (auth.jwt.refresh_ttl_secs)/
navree_2faA pending two-factor challengeAs long as the challenge (5 minutes)/api/auth/2fa

Accounts with two-factor authentication get a challenge instead of tokens. The challenge waits in navree_2fa, which only the code step receives. The login page then posts {code} or {recovery_code} to /api/auth/2fa/verify, and the BFF adds the challenge token, calls Navree and stores the tokens like a normal login. A wrong code keeps the challenge, so the person can try again until it expires.

Forwarding requests

Every other /api/* request goes through one catch-all route. In Nuxt a file without a method suffix answers every method:

apps/crm-web/src/app/api/[...path]/route.ts

proxy sends the request to the same path on the API with:

  • Authorization: Bearer and the access token from navree_at;
  • an allow-list of request headers: accept, accept-language, content-type, if-none-match, if-modified-since, user-agent and x-request-id. Cookies never leave the BFF;
  • the body, buffered so it can be replayed after a refresh.

The API's response comes back with its status, its body (streamed, so a CSV export isn't buffered) and an allow-list of headers, including etag, location, retry-after, x-request-id and the x-ratelimit-* headers. Authenticated responses are fetched with cache: "no-store", so they never land in a shared cache.

When an /api/auth/* response carries a token pair (changing the password returns a new one), the BFF moves the pair into the cookies and strips it from the body. The browser gets {user} or a 204.

Rotating refresh tokens

Navree's access tokens are short-lived JWTs, and its refresh tokens rotate: every refresh revokes the token it was given and issues the next one in the same family. A refresh token that is used twice means two parties hold it, so Navree revokes the whole family and the session ends. See Rotation and families.

That makes refreshing from a browser app subtle. A page that fires five requests the moment the access token expires gets five 401s, and five refreshes with the same token would end the session. The BFF therefore refreshes once per refresh token, and every concurrent request waits for that one call:

apps/crm-web/src/lib/bff/bff.ts

The rules proxy follows:

  1. If the access cookie has expired but the refresh cookie remains, refresh before sending.
  2. Send the request. On a 401, refresh once and retry with the new access token.
  3. When a refresh succeeds, set both cookies on the response.
  4. When it fails, clear both cookies and pass the 401 on. The browser's API client sends the user to /login.

A completed refresh is remembered for 30 seconds (refreshGraceMs), so a request that was already in flight with the old cookie gets the new pair instead of spending the old token a second time.

The single-flight map lives in one server process. When you run several BFF processes behind a load balancer, route each browser to one of them (sticky sessions), or two processes could spend the same refresh token at once and end the session.

Signing out

POST /api/auth/logout revokes the refresh token at Navree, so the stolen copy of a cookie is worthless afterwards, then clears both cookies. If the access token has already expired, the BFF refreshes first to get a bearer token for the logout call. If Navree can't be reached, the browser is signed out anyway. Send {"all": true} to end every session of the user.

CSRF protection

Cookies are sent with every request to their origin, so a page on another site could submit a form to the BFF and ride on the user's session. Two layers stop it:

  • SameSite=Lax keeps the cookies off cross-site POST, PUT, PATCH and DELETE requests in browsers that support it.
  • An origin check on every request that can change data. A request other than GET, HEAD or OPTIONS must carry an Origin header naming the app's own host (or one listed in APP_ORIGINS). A missing or foreign origin gets a 403 and never reaches the API:
apps/crm-web/src/lib/bff/bff.ts

Login, logout and the two-factor step are guarded the same way. Keep GET requests free of side effects and the two layers cover every route.

The API itself needs no CSRF token for this setup: it only ever sees bearer tokens, and Navree's session CSRF check applies to cookie sessions, not to bearer requests. If your app also signs users in with sessions, that check covers those requests.

Client addresses and rate limits

Navree's login throttle and per-IP rate limit key on the client's address. Behind a BFF, every browser reaches the API from the BFF's address, so all users would share one limit, and one person's wrong passwords would lock everyone out of that account.

The BFF can name the browser in X-Forwarded-For, but only when it can trust the address itself:

  • The browser's own X-Forwarded-For and X-Real-IP are never passed on: anyone can forge them.
  • Put the Next.js server behind a reverse proxy that appends the client to X-Forwarded-For, make Next.js reachable only through it, and set NAVREE_TRUSTED_PROXY_HOPS to the number of such proxies. The BFF then sends the API one X-Forwarded-For entry: the address the outermost proxy saw.

On the API side, list the BFF's address in http.trusted_proxies, so the API believes the header only from the BFF:

apps/crm-api/config/app.toml

The CRM tests this from the API's side: six wrong passwords from one browser are throttled, and another browser signing in to the same account still succeeds.

apps/crm-api/tests/bff.rs

Real time through the BFF

The WebSocket goes straight from the browser to Navree's WebSocket server: it carries no token, only a socket id. Private channels need authorization, and that request needs the user's credentials, so it goes through the BFF like any other call:

apps/crm-web/src/app/broadcasting/auth/route.ts

The Nuxt route lives under server/routes/ rather than server/api/, so it answers at /broadcasting/auth with no /api prefix, the same path as in the Next.js app.

The browser's pusher-js client uses a custom authorizer that posts {socket_id, channel_name} to that same-origin route. Navree checks the channel against the user's access rules and signs the subscription. List the web app's origin in realtime.allowed_origins so the WebSocket handshake is accepted. If the socket can't connect, the CRM keeps working with refetch-on-focus. See Broadcasting.

Calling the API from the browser

The browser's API client is generated from the API's OpenAPI document and points at the BFF, so it needs no token handling at all: same-origin credentials, Accept: application/json, and hooks for 401 and 403. Both front ends use openapi-fetch with types from openapi-typescript; the Nuxt app's client is app/lib/api/client.ts.

apps/crm-web/src/lib/api/client.ts

Because the BFF keeps the API's paths, client.GET("/api/v1/deals/board") is checked against the document by the TypeScript compiler and lands on the same route at the API. See API-first.

Configuration

The BFF reads its settings from the environment when the server runs, not when it is built, so they change without a rebuild. The Nuxt app reads the same names when its BFF is first used (NUXT_NAVREE_API_URL and NUXT_COOKIE_SECURE work too):

VariableDefaultWhat it does
NAVREE_API_URLhttp://127.0.0.1:8080Where the Navree API listens.
NAVREE_COOKIE_SECUREon in production0 or 1: the Secure flag on the cookies. Set 0 only to serve a production build over plain HTTP in a test environment.
APP_ORIGINSnoneExtra origins allowed to send writes, comma-separated: the public URL when a proxy rewrites Host.
NAVREE_TRUSTED_PROXY_HOPS0Reverse proxies in front of the BFF that append the client to X-Forwarded-For.
NEXT_PUBLIC_WS_HOST, NEXT_PUBLIC_WS_PORT127.0.0.1, 8080The WebSocket server the browser connects to.
NEXT_PUBLIC_PUSHER_KEYnavree-crm-keyThe app key, realtime.key on the API.
NEXT_PUBLIC_REALTIMEon0 turns real time off.

The Nuxt app takes the browser settings as public runtime config, so they too change without a rebuild: NUXT_PUBLIC_WS_HOST, NUXT_PUBLIC_WS_PORT, NUXT_PUBLIC_PUSHER_KEY and NUXT_PUBLIC_REALTIME. In the CRM its NAVREE_API_URL and NUXT_PUBLIC_WS_PORT default to port 8092, where pnpm api runs a copy of the API for that app.

On the API, nothing is BFF-specific beyond http.trusted_proxies and realtime.allowed_origins. Two settings are worth a look:

  • auth.registration = false when accounts are created by admins, as in the CRM. The BFF then has no sign-up route to forward.
  • auth.jwt.audience to issue tokens for one audience ("crm-web"), so a token minted for the web app is refused by another API that checks a different audience.

Testing

The BFF is framework-free TypeScript: createBff takes the API URL and a fetch, so its unit tests run against a scripted fake API without a server. This one proves that three concurrent requests share one refresh:

apps/crm-web/src/lib/bff/bff.test.ts

The same suite covers the origin check, login and logout, two-factor sign-in, tokens stripped from responses and the client-address rules. On top of that:

  • apps/crm-nuxt runs the same kind of suite against its own BFF (tests/unit/bff.test.ts), and tests/unit/nitro-handlers.test.ts drives the real Nitro route handlers and the session gate, mounted on an h3 app the way Nitro mounts them: login keeps both tokens in httpOnly cookies, an expired session refreshes once and rotates both cookies, a cross-origin mutation gets a 403.
  • apps/crm-api/tests/bff.rs checks the API's side: per-browser throttling and rate limits behind a trusted BFF.
  • E2E_REAL_API=1 pnpm e2e in apps/crm-web, and pnpm e2e in apps/crm-nuxt, run the Playwright specs against the real API and a production build of the front end, end to end. The Nuxt app's bff.spec.ts checks the CSRF refusal, the refresh-cookie rotation and that signing out revokes the refresh token at the API.

Starting your own

navree new generates the pattern for you:

Shell

--ui next writes web/lib/bff.ts with sign-in, sign-out, forwarding, a refresh on 401 and the origin check, and navree dev starts both servers. The SaaS starter kit builds on the CRM's complete Next.js BFF. To see both running, see the CRM showcase.

The CRM's BFF is also a package: @navree/bff has everything on this page, plus sessions for server rendering and test helpers, and @navree/next and @navree/nuxt mount it in a Next.js or Nuxt app in a few lines.