Command Palette

Search for a command to run...

Writing / Security

The browser shouldn't hold your JWT: per-request auth in a BFF

Jun 16, 2026·5 min read
AuthJWTGoSecurity

The default way a single-page app does auth is to get a JWT, stash it in localStorage or a JS-readable cookie, and attach it to every API call. It works, it's in every tutorial, and it puts a bearer token — the thing that is the user's session — within reach of any script that runs on the page.

There's a different arrangement that hands the browser no token at all. It falls out naturally once you have a gateway, and in Microlearning there was already a reason to have one.

Why there's a gateway at all

A single learner screen — progress, profile, entitlements, assessment state, notifications — needs data spread across roughly eight internal services. The obvious shape is to let the frontend call what it needs.

That shape gets expensive fast. Eight services means eight sets of credentials that have to live somewhere the browser can reach, which means in the browser. It means eight CORS configurations and eight token-refresh paths. And it means the public internet has a direct line to eight internal surfaces instead of one, because "the frontend talks to what it needs" is also "an attacker reaches whatever the frontend could."

So there's a Backend-for-Frontend in front: one request in, one composed response out, every downstream credential held server-side. Once that component exists, it's the only sensible place for authentication to live — it's already the single thing the browser talks to, and already the only thing holding credentials.

The browser authenticates into an HttpOnly, signed-and-encrypted session cookie, plus a CSRF token. That is the entire set of things it holds. It never sees the JWT. The access token — a Cognito JWT — lives inside that encrypted server-side session, and the BFF is what presents it, or a downstream credential, to internal services.

The browser's job shrinks to "send my cookie." Everything that used to be the SPA's auth responsibility — holding the token, validating it, refreshing it, attaching the right credential to the right service — moves behind the boundary.

Per-request resolution in middleware

On every request, auth middleware does three things before any real work happens:

  1. Pulls the access token out of the encrypted session, not a header the client controls.
  2. Verifies it against the identity provider's JWKS — selecting the RSA public key by the token's kid, rebuilding that key, checking the signature. The key set is fetched and cached.
  3. Rejects anything missing or invalid with a 401.

Then a shared routine resolves the user's context — locale, contract, first-run flags — so every downstream call is made as a fully-resolved identity rather than a raw token the next service has to re-interpret.

func (a *Auth) Middleware(c *gin.Context) {
    token := a.session(c).Get("access") // from the encrypted cookie, not a header
    claims, err := a.parseJWT(token)     // JWKS: pick the RSA key by kid, verify
    if err != nil {
        c.AbortWithStatusJSON(http.StatusUnauthorized, errorBody)
        return
    }
    c.Set("user", a.resolveContext(claims)) // locale, contract, first-run flags
    c.Next()
}

That resolveContext step is the quiet workhorse. Without it, each downstream call re-derives "who is this and what should they see" from a raw token independently — eight times, with eight chances to get it subtly differently. Resolving it once per request means every service sees a consistent caller.

The refresh dance stays server-side

Tokens expire. Instead of the SPA juggling refresh tokens, the BFF owns the lifecycle and the client just retries. A single response interceptor turns a 401 into a one-shot refresh — mint a fresh CSRF token, call the refresh endpoint, replay the original request, and only bounce to the login page if that fails. A module-level guard flag stops a failing refresh from looping. The browser never handles a refresh token; it occasionally notices a request took two hops instead of one.

What you get

  • XSS can't steal the session. There's no token in JS storage to read. An injected script can still act as the user while the page is open — it's same-origin — but it can't exfiltrate a credential to replay later. That's the difference between a contained incident and a stolen session that outlives the tab.
  • One place for auth. Verification, context resolution, refresh, and downstream credential injection all live in the BFF. The SPA's entire auth footprint is "retry on 401."
  • Downstream services get a clean identity — a resolved user context, not a third-party JWT each of them validates independently.

What it costs

  • CSRF is back on the menu. Cookies get attached automatically, which is exactly what CSRF abuses — so you need the double-submit token, and SameSite alongside it. A JWT-in-a-header SPA sidesteps CSRF precisely because the browser doesn't auto-send the credential.
  • The BFF is on the critical path for every request, and it's stateful about sessions — that encrypted cookie — even where it's otherwise a stateless aggregator holding no database.
  • You depend on the JWKS being reachable and cached, with a real plan for rotation. An unrecognised kid needs to trigger a re-fetch; if it triggers a hard failure instead, a routine key rotation locks every user out until someone redeploys.

The trade, stated plainly

"Cookie and CSRF at the edge, JWT only upstream" buys the deletion of an entire class of token-theft-from-the-client bugs, and pays for it in CSRF handling and a gateway that can never be optional. For a product holding reflective, personal learning data, that's an easy trade. For a public API with no browser client it would be the wrong one entirely — there's no XSS surface to protect, and nothing gained by putting a session in front of a token.