Skip to content

Your React app should never hold an auth token

5 min read

  • auth
  • react
  • architecture
Access tokens in JavaScript are readable by any script on the page. A thin backend-for-frontend removes the problem instead of managing it.

Move the token out of the browser entirely. A small backend-for-frontend holds it, sets an httpOnly cookie the page cannot read, and attaches the Authorization header server-side. The React app never sees a credential, so there is nothing for an injected script to steal.

That is the whole argument, and the rest of this post is why the alternatives lose. The short version: every other option is a different answer to "where do we keep the token so that only our code can read it", and in a browser that question has no good answer, because "our code" is not a category the runtime recognises.

Every storage option fails the same way

localStorage is readable by every script on the origin. So is sessionStorage. So is a token parked in a module-scoped variable, which people reach for believing that closure is a security boundary — it is not, it is a naming convention. React DevTools will show you someone else's token in their app state. So will a debugger statement in a dependency's postinstall script.

The usual response is to harden the app until XSS is impossible: sanitise everything, set a strict CSP, audit the dependency tree. All of that is worth doing. None of it is a bet you can win permanently, because you have to win it every day and on every transitive dependency, and the attacker has to win it once.

Here is the part I think gets argued backwards. The debate is usually framed as localStorage versus httpOnly cookies, and the pro-localStorage side has a genuinely good point: cookies bring CSRF, and CSRF is a real vulnerability with a real remediation cost. But that comparison is between two places to keep a credential in the browser. The BFF is not a third place. It is the decision not to have one.

What the BFF actually is

Not a microservice. Not a gateway. It is a thin server-side layer that sits between your React app and your API, and it does four things:

  1. Completes the OAuth flow and receives the tokens.
  2. Stores them server-side, keyed to a session.
  3. Sets an httpOnly, Secure, SameSite cookie holding nothing but a session identifier.
  4. Proxies API calls, attaching the Authorization header on the way through.

In a Next.js app it is a route handler. That is the entire footprint.

app/api/[...path]/route.ts
import { cookies } from "next/headers";
import { getSession } from "@/server/session";
 
async function proxy(request: Request, path: string[]) {
  const store = await cookies();
  const session = await getSession(store.get("sid")?.value);
 
  if (!session) {
    return Response.json({ error: "unauthenticated" }, { status: 401 });
  }
 
  const upstream = await fetch(`${process.env.API_URL}/${path.join("/")}`, {
    method: request.method,
    headers: {
      // The one line the browser is never allowed to write.
      Authorization: `Bearer ${session.accessToken}`,
      "Content-Type": request.headers.get("Content-Type") ?? "application/json",
    },
    body: request.method === "GET" ? undefined : await request.text(),
  });
 
  return new Response(upstream.body, {
    status: upstream.status,
    headers: { "Content-Type": upstream.headers.get("Content-Type") ?? "" },
  });
}
 
export async function GET(request: Request, { params }: Ctx) {
  return proxy(request, (await params).path);
}

The client, meanwhile, gets simpler rather than more complicated:

lib/api.ts
export async function api<T>(path: string, init?: RequestInit): Promise<T> {
  const response = await fetch(`/api/${path}`, {
    ...init,
    // Sends the session cookie. There is no header to build, no token to
    // read, and no refresh logic on this side of the wire.
    credentials: "same-origin",
  });
 
  if (!response.ok) throw await ApiError.from(response);
  return response.json() as Promise<T>;
}

Notice what is missing. No interceptor reading from storage. No "is the token expired" check duplicated across call sites. No token in the Redux store that somebody will eventually log to Sentry along with the rest of the state snapshot.

Then CSRF is your problem, and that is fine

Cookies are sent automatically, which is the property that makes them useful and the property that makes CSRF possible. This is the real cost of the approach and it deserves a straight answer rather than a footnote.

SameSite=Lax on the session cookie stops the classic cross-site form post, because the browser will not attach the cookie to a cross-origin POST. That covers most of it. It is not everything — same-site subdomains are still same-site, so evil.yourcompany.com is not blocked by Lax — so the proxy also checks Origin on anything that mutates:

server/csrf.ts
const ALLOWED = new Set([process.env.APP_ORIGIN]);
 
export function assertSameOrigin(request: Request) {
  if (["GET", "HEAD", "OPTIONS"].includes(request.method)) return;
 
  // Fetch and XHR always send Origin on cross-origin requests, and browsers
  // will not let a page forge it. A missing Origin on a mutation is itself
  // suspicious enough to reject.
  const origin = request.headers.get("Origin");
  if (!origin || !ALLOWED.has(origin)) {
    throw new HttpError(403, "cross-origin request rejected");
  }
}

Two mechanisms, both declarative, both in one file. Compare that with the alternative you are choosing against: a token in localStorage needs no CSRF defence, and in exchange every script on the page can read it forever. I would rather own a bounded, well-understood problem with a known remediation than an unbounded one whose remediation is "have no bugs".

The trade-offs I would not talk you out of

You need a server. If your app is a static bundle on a CDN with no runtime of any kind, this pattern is not available to you and the honest answer is a public client with PKCE and short-lived tokens. But most React apps that hold a credential are already running Next.js, Remix or an Express server, and are one route handler away.

Every API call gains a hop. The proxy adds latency — single-digit milliseconds if it is colocated with the API, meaningfully more if it is not. Colocate it. If you cannot, measure the difference before deciding it does not matter.

The proxy becomes infrastructure. It needs the same rate limiting, the same timeouts and the same observability as anything else on the request path, because it is now on the request path. That is real work and it is easy to forget on the day you ship it.

Session storage becomes a decision. Somewhere has to hold the tokens server-side. Redis with a TTL is the boring answer and the boring answer is correct. Encrypted stateless cookies work too, and buy you a different problem: you cannot revoke them.

What this does not solve

An httpOnly cookie stops exfiltration. It does not stop an injected script from calling your API as the user from the page it has already compromised. Defence in depth still applies — CSP, subresource integrity, a dependency policy — and the BFF makes all of them more valuable rather than less, because now the attacker has to keep the tab open to keep the access.

It also does not solve the second half of the problem, which is what happens when the access token expires while six requests are already in the air. That is a genuinely interesting piece of state machinery, and it is the next post.

The rule I would actually enforce

If a credential is readable by JavaScript, it belongs to whoever controls the page, and you do not fully control the page. Everything else follows from that.

The version of this I would put in a code review is narrower and easier to apply: no code in src/ should be able to name a token. Not read one, not receive one, not type one. If a variable of type AccessToken can exist in the client bundle, the boundary is in the wrong place — and a boundary you can see in the type system is one that survives the next six months of people who did not read this post.