The BFF / Same-Site Proxy Pattern
Keep tokens off the browser entirely: your own server holds them and the browser only ever sees a first-party cookie.
The Backend For Frontend pattern sidesteps the entire cross-site question. The browser never receives a token and never talks to dfg.com. It talks to your server on its own origin, and that server calls the API.
The shape
browser (abc.com)
│ first-party session cookie — SameSite=Lax, HttpOnly
▼
BFF (abc.com/api/*) ← your Next.js route handlers, or a small proxy
│ Authorization: Bearer <token> (server to server, over TLS)
▼
API (dfg.com)What this buys you
- No token in the browser. XSS cannot steal what was never sent. This is the strongest storage answer available.
- First-party cookies.
SameSite=Lax, no third-party blocking, works in every browser. - No CORS at all for the browser — every call is same-origin.
- Secrets stay server-side. OAuth client secrets and API keys live on your server, so you can use confidential-client flows.
- One place to add caching, rate limiting, and response shaping.
What it costs
- An extra hop of latency, and a server you must run and scale.
- CSRF is back — you are cookie-authenticated again, so you need
SameSiteplus a CSRF token. - Streaming, uploads and websockets need deliberate proxying.
It is what your framework already wants
Next.js route handlers, Remix loaders, SvelteKit endpoints and Nuxt server routes are all BFFs. If you are already running a server-rendering framework, you have one — using it for auth is a small step, not new infrastructure.
The variant: a reverse proxy
If you do not want application code in the middle, nginx or a CDN can map abc.com/api/* to dfg.com/*. The browser sees one origin; you attach credentials at the proxy. Less flexible, close to zero code.
Example
// app/api/orders/route.ts — a Next.js BFF route on abc.com
import { cookies } from 'next/headers';
export async function GET() {
// 1. Read the FIRST-PARTY session cookie (HttpOnly, SameSite=Lax)
const session = await readSession((await cookies()).get('sid')?.value);
if (!session) return Response.json({ error: 'unauthenticated' }, { status: 401 });
// 2. Call the real API server-to-server with a token the browser never sees
const res = await fetch('https://dfg.com/api/orders', {
headers: { Authorization: `Bearer ${await tokenFor(session.userId)}` },
cache: 'no-store',
});
// 3. Return only what the browser needs
return Response.json(await res.json(), { status: res.status });
}When to use it
- A Next.js app keeps OAuth access tokens server-side and hands the browser only a signed session cookie, so an XSS cannot exfiltrate a portable credential.
- A team fronts three internal APIs behind one BFF so the SPA makes one same-origin call instead of three cross-origin ones.
- An nginx rule maps abc.com/api/ to the API host, eliminating CORS configuration entirely with no application code.
More examples
A generic proxy route with the rules that matter
Dropping the client's Authorization header is the security-critical line: without it, a caller could supply their own token and the proxy would forward it.
// app/api/[...path]/route.ts — forward anything under /api to the real API
import { cookies } from 'next/headers';
const API = 'https://dfg.com';
const HOP_BY_HOP = new Set(['connection', 'keep-alive', 'transfer-encoding',
'upgrade', 'host', 'content-length']);
async function proxy(req: Request, { params }) {
const { path } = await params;
const session = await readSession((await cookies()).get('sid')?.value);
if (!session) return Response.json({ error: 'unauthenticated' }, { status: 401 });
const url = new URL(req.url);
const target = `${API}/${path.join('/')}${url.search}`;
// Forward the client's headers MINUS anything that would confuse the upstream,
// and never forward a client-supplied Authorization — we set our own.
const headers = new Headers();
for (const [k, v] of req.headers) {
if (!HOP_BY_HOP.has(k.toLowerCase()) && k.toLowerCase() !== 'authorization') {
headers.set(k, v);
}
}
headers.set('Authorization', `Bearer ${await tokenFor(session.userId)}`);
headers.set('X-Forwarded-For', req.headers.get('x-forwarded-for') ?? '');
const upstream = await fetch(target, {
method: req.method,
headers,
body: ['GET', 'HEAD'].includes(req.method) ? undefined : req.body,
duplex: 'half', // required when streaming a request body
cache: 'no-store',
});
// Stream the response back; strip upstream cookies so they never reach the browser
const out = new Headers(upstream.headers);
out.delete('set-cookie');
return new Response(upstream.body, { status: upstream.status, headers: out });
}
export { proxy as GET, proxy as POST, proxy as PUT, proxy as PATCH, proxy as DELETE };The zero-code version, in nginx
For a static SPA plus a third-party API this is often all you need — no BFF application, no CORS configuration, and no token in the browser.
server {
listen 443 ssl;
server_name abc.com;
# The SPA
location / {
proxy_pass http://frontend:3000;
}
# Same-origin API path → the real API on another host
location /api/ {
proxy_pass https://dfg.com/;
proxy_set_header Host dfg.com;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Attach the credential here so the browser never holds it
proxy_set_header Authorization "Bearer $api_token";
# And make sure the client cannot supply its own
proxy_set_header X-Api-Key "";
}
}
# From the browser's point of view there is exactly one origin:
# fetch('/api/orders') ← same-origin, no CORS, no preflight, Lax cookies
Discussion