3.3 KiB
ADR-0015: Frontends reach the API through their own origin
- Status: Accepted
- Date: 2026-08-12
Context
The refresh token is delivered as an httpOnly cookie (ADR-0008) — that is what stops an XSS from stealing the credential that mints new sessions.
Cookies are governed by SameSite. If the browser calls api.example.com from
admin.example.com, that is a cross-site request, and the cookie only rides
along with SameSite=None. SameSite=None requires Secure, which requires
HTTPS. Local development runs on plain HTTP, so the cookie would simply never be
set — presenting as a login that "succeeds" and then immediately forgets you.
The alternatives are all worse: put the refresh token in localStorage (readable
by any script, which defeats the entire httpOnly design), run local development
over self-signed HTTPS (friction on every machine and in CI), or accept that dev
and production authenticate differently (the class of bug you only find in
staging).
Decision
The browser always calls the API on the same origin as the page it is on.
- Production: Nginx already routes
/api/to the API container on the same host — this was in the topology from milestone 0. - Development: a Next.js
rewrites()entry maps/api/:path*toAPI_INTERNAL_URL, reproducing that topology exactly. browserApiis therefore created withbaseUrl: '', andHttpClientresolves a relative base againstwindow.location.origin.
Server-side rendering is unaffected: it calls API_INTERNAL_URL directly over
the internal network, because there is no cookie and no browser involved.
The cookie is then first-party, SameSite=Lax, httpOnly, Secure in
production, and scoped to path=/api/v1/auth so it is attached to two endpoints
rather than every API call.
Consequences
Development and production share one authentication topology, so a cookie problem is reproducible locally instead of appearing first in staging.
CORS effectively disappears for browser traffic — the requests are same-origin. The API's CORS config remains for non-browser and tooling access.
Storefront and admin get separately named cookies (sport_refresh,
sport_admin_refresh). Sharing a name would mean signing into the admin
silently replaced a customer session in the same browser.
The costs, stated plainly:
- One extra network hop in development (browser → Next → API). Irrelevant locally, and absent in production where Nginx was already the front door.
- The frontends now have a route namespace (
/api/*) they must not use for their own route handlers. Worth noting in review; neither app has any. API_INTERNAL_URLbecomes required for the dev rewrite, not just for SSR.
Alternatives considered
Refresh token in localStorage. Removes the cookie problem entirely and
removes the security property with it — any injected script can read it.
Rejected outright.
SameSite=None; Secure with HTTPS in development. Correct, but forces every
developer and CI job to trust a local certificate. Rejected as friction that
buys nothing production does not already provide.
A dedicated auth subdomain with a parent-domain cookie. Works in production,
but localhost has no usable parent domain, so development still diverges.
Rejected for the same reason as above.