Proxying API Requests Without Leaking Tokens

A backend-for-frontend earns its place by holding tokens the browser never sees — and loses it the moment the proxy forwards whatever the browser asks for with those tokens attached. This page is part of the BFF pattern for SPAs guide and covers the forwarding rules that keep the pattern’s guarantee intact.

The Confused Deputy Problem

A proxy that maps /api/* onto an internal base URL is an open door with your credentials on it. The browser — or an injected script, or a bug — chooses the path, and your server attaches an access token and calls it. Every internal service reachable from the proxy becomes reachable by the client.

Wildcard forwarding versus an explicit route allowlist A wildcard rule forwards any path the client supplies with the access token attached, reaching internal services; an allowlist maps only known routes and returns 404 for anything else. Wildcard forwarding /api/* → https://internal/* the client chooses the target internal services become client-reachable Explicit allowlist GET /api/invoices → …/invoices you choose the target anything unlisted returns 404
The allowlist is not bureaucracy — it is the difference between a proxy that exposes one API and one that exposes your whole internal network.

Keep the map in code, one entry per method and path, with the upstream target written out. When a route needs a parameter, validate its shape before substitution rather than interpolating whatever arrived, or a path segment containing .. or an encoded slash turns your allowlist back into a wildcard.

Header Hygiene

Decide explicitly, for each header, whether it is forwarded, replaced, or dropped. The defaults in most proxy libraries forward far more than you want.

const FORWARD = ["content-type", "accept", "accept-language", "x-correlation-id"];

function outboundHeaders(req: Request, accessToken: string) {
  const headers = new Headers();
  for (const name of FORWARD) {
    const value = req.get(name);
    if (value) headers.set(name, value);
  }
  headers.set("authorization", `Bearer ${accessToken}`);   // set by us, never forwarded
  headers.set("x-forwarded-for", req.ip);                  // for upstream rate limiting
  return headers;                                          // cookies are deliberately absent
}

Three rules matter most. Never forward the inbound Authorization header: the browser has no legitimate reason to send one, and honouring it lets a client substitute its own credential for the one you would have attached. Never forward cookies upstream: internal services have no use for them, and passing session material outward widens its blast radius. And never forward hop-by-hop headers or an inbound X-Forwarded-* set that the client controls, because upstream services may make decisions on them.

On the way back, strip anything the browser should not receive — internal service names, stack traces, upstream Set-Cookie headers — and set Cache-Control: private, no-store on every authenticated response.

Streaming, Size Limits and Timeouts

Resource controls on a proxy tier Streaming bodies rather than buffering keeps memory bounded, an explicit body size cap prevents exhaustion, and per-request timeouts stop a slow upstream from consuming every handler. Stream never buffer whole bodies memory stays bounded Cap size explicit body limit reject early, not at the end Time out per-request deadline a slow upstream cannot stall you
The proxy sits on every request, so its resource behaviour is your application's resource behaviour.

For large downloads of user content, prefer issuing a short-lived signed URL and letting the browser fetch directly from storage. The authorization decision still happens on your server; the bytes do not pass through it. That single change removes the most common reason a proxy tier needs to be scaled separately from the rest of the application.

Authorization Still Belongs Upstream

It is tempting to filter responses in the proxy, because it is the last place before the browser. Resist it: if the upstream returned data this caller should not see, every other consumer of that service inherits the same exposure, and you have moved a security control to the one layer that does not own the data.

The proxy’s job is coarse: is there a session, does this route exist, may this caller reach this route at all. The service’s job is fine: does this record belong to this tenant, is this object owned by this user. Keeping the split clean is what makes the system reviewable — and it is the same reasoning behind where enforcement points belong.

Error Handling That Does Not Leak

Mapping upstream responses to client responses Client-meaningful statuses pass through unchanged, upstream authentication failures become a 502 because they indicate a problem with the proxy's own token, and every other server error collapses into a generic failure with a correlation identifier. 400 · 404 · 409 · 422 pass through — the client can act on these 401 from upstream our token failed — return 502, alert, never 401 5xx and connection errors generic failure plus a correlation id
Forwarding an upstream 401 to the browser is the specific mistake that produces an endless login loop for a problem the user cannot fix.

Upstream failures are where proxies quietly become information disclosure. An internal 500 with a stack trace, a 404 that reveals a service name, or a connection error naming an internal hostname all reach the browser unless the proxy decides otherwise.

Map upstream statuses deliberately. Pass through the ones that mean something to the client — 200, 201, 400, 404 for a resource the caller may know about, 409, 422 — and collapse everything else into a generic 502 or 503 with a correlation identifier the user can quote to support. Never forward an upstream response body you have not inspected; a JSON error from an internal service is written for internal consumption and frequently names hosts, queries or identifiers.

Distinguish upstream failure from authentication failure in the response you produce. A 401 from an internal service usually means the token you attached was rejected, which is your problem rather than the caller’s — returning it verbatim tells the browser to re-authenticate, producing a login loop that resolves nothing. Log it, alert on it, and return a 502.

Observability for the Proxy Tier

Because the proxy sits between everything, its metrics are the fastest way to understand the whole system — and the easiest to get wrong by measuring the wrong thing.

Record proxy-added latency, not total latency: subtract the upstream duration from the request duration and watch that number. Total latency tells you about your services; the difference tells you about your proxy, and it is the number that will be quoted when someone proposes removing the tier.

Track the ratio of 401s returned to the browser against successful requests. A step change usually means the refresh path is broken rather than that users are logged out, and catching that from a graph is far quicker than from support tickets.

Emit a correlation identifier on every request, forward it upstream, and include it in error responses. Being able to follow one user’s failing request from the browser through the proxy to the service that refused it turns most support conversations into a single query.

Alert on the proxy’s own error rate separately from the upstreams’, because the two have different owners and different fixes. A rise in upstream 5xx is a service problem; a rise in proxy 502s with healthy upstreams usually means a token, a timeout or a connection-pool setting on the proxy itself.

Finally, sample and store the route-level distribution of upstream statuses. The first sign that an internal API has changed behaviour is usually a shift in that distribution, days before anyone reports a functional problem. Keep the retention long enough to compare against the previous release, because “this started after Tuesday’s deploy” is the single most useful sentence in an investigation and it requires data from before Tuesday. Where the proxy fronts several upstreams, break the distribution down per upstream as well as per route, so a change in one service does not disappear into the aggregate of the others.

Frequently Asked Questions

Can I forward all headers and just strip a few?

An allowlist is safer than a denylist here, for the same reason it is everywhere else: the list of headers you must not forward grows over time, and you will not notice when a new one appears. Forward the handful your upstreams actually read, set the ones you own, and drop everything else. The list is short and it stops surprising you.

Should the proxy retry failed upstream calls?

Only idempotent requests, and only a small number of times with a short budget. Retrying a POST can duplicate a payment; retrying anything for several seconds turns an upstream slowdown into exhausted handlers on the proxy. Give the whole request one deadline, let the client decide whether to try again, and surface the upstream status rather than masking it.

How do I handle file uploads?

Stream them with an explicit size cap, or bypass the proxy entirely with a signed upload URL issued after an authorization check. Buffering uploads in the proxy is the shape that falls over first under load, because a handful of concurrent large files can consume the memory of a tier that every other request depends on.

What should the proxy log?

The route, the outcome, the upstream latency and a correlation identifier — never the token, never request bodies, and never response bodies. A proxy log that captures payloads becomes a copy of your users’ data in a system with different retention and access rules than the service that owns it.

Does the proxy need its own rate limiting?

Yes, per session and per route. It is the only component that sees every request from a given browser, which makes it the natural place to bound abuse before it reaches services that scale less easily. Keep the limits generous enough that ordinary use never notices, and log rejections so you can tell a broken client from an attack.

Review the route map on a schedule rather than only when adding to it, because entries accumulate for features that were later removed and each one is a path into an internal service that nothing is testing any more.