Over the last few days I went deep into one of those deceptively simple auth problems that turns into a browser security rabbit hole.
The original goal sounded straightforward:
Move the platform from localStorage JWT auth toward secure cookie-based authentication across multiple microservices.
But once I started reconciling the actual implementation against the original migration epic, I realized the real problem wasn’t JWTs.
It was understanding:
- same-origin vs same-site,
- how browsers attach cookies,
- whether SameSite=None was actually necessary,
- and how deployment topology changes security behavior.
The Existing Architecture:
The platform is composed of multiple services:
- login/auth service
- BFF
- billing
- notification
- frontend application
The frontend was still heavily localStorage-token based:
Authorization: Bearer ${localStorage.getItem("token")}
Meanwhile:
OTP login was already cookie-based
backends already supported:
- credentials: true
- cookie parsing
- auth middleware
So the backend groundwork for cookie auth was mostly there.
The original migration epic proposed:
- switch cookies to SameSite=None; Secure
- move frontend to cookie auth
- remove bearer-token usage
- stop returning access tokens in login responses
But there was a problem.
The Security Contradiction
While reviewing the actual codebase, I noticed something important:
The auth cookies had already been hardened to:
SameSite=Strict
That change was intentional.
It had been introduced earlier to close a CSRF exposure.
So now there was a contradiction:
the migration epic wanted SameSite=None
but security hardening had intentionally moved to Strict
That immediately raised the question:
Do we even need SameSite=None?
And the answer depends entirely on deployment topology.
Same-Origin vs Same-Site
This turned out to be the key distinction.
Same-Origin
An origin is:
scheme + host + port
Examples:
| URL A | URL B | Same-Origin? |
|---|---|---|
https://app.company.com |
https://app.company.com/x |
Yes |
https://app.company.com |
https://api.company.com |
No |
http://localhost:5104 |
http://localhost:3001 |
No |
Same-origin controls:
- CORS
- JS access
- localStorage isolation
Same-Site
A site is roughly the registrable domain (eTLD+1).
Examples:
| URL A | URL B | Same-Site? |
|---|---|---|
https://app.company.com |
https://api.company.com |
Yes |
http://localhost:5104 |
http://localhost:3001 |
Yes |
https://company.com |
https://billing.io |
No |
This distinction matters because:
SameSite cookie behavior operates at the site level, not the origin level.
That means:
app.company.com -> api.company.com
is:
- cross-origin
- but still same-site
So SameSite=Strict cookies still work there.
That realization changed the entire migration plan.
Reverse Proxy Topology Changes Everything
The next major insight came from analyzing nginx routing.
There are two fundamentally different deployment models.
Option A — Single Gateway (Same-Origin)
Browser sees:
https://app.company.com/
https://app.company.com/auth/*
https://app.company.com/billing/*
https://app.company.com/notifications/*
Internally nginx fans requests out to different services:
auth:3001
billing:3009
notifications:3010
But the browser never sees that.
So to the browser:
- everything is same-origin
- cookies are trivially attached
- no SameSite=None
- minimal CORS complexity
This is the cleanest architecture.
Option B — Separate API Hostnames
Browser sees:
https://app.company.com
https://api.company.com
This becomes:
- cross-origin
- but same-site
In this setup:
SameSite=Strict still works
but CORS credentials become mandatory
Example:
fetch(url, {
credentials: "include"
})
and server-side:
credentials: true
Important Realization
A reverse proxy alone does NOT automatically make things same-origin.
What matters is:
what hostname the browser sees.
That was probably the biggest conceptual breakthrough in this debugging session.
SPA Routing Pitfalls
I also explored consolidating multiple SPAs under one gateway.
Example:
location /billing-app/ {
alias /usr/share/nginx/billing/;
try_files $uri $uri/ /billing-app/index.html;
}
This introduces several subtle problems.
1. Prefix collisions
If:
/billing/
already proxies billing APIs, then the frontend cannot also live there.
Solution:
- /billing/ for APIs
- /billing-app/ for SPA
2. Vite base path issues
Frontend builds must specify:
base: "/billing-app/"
Otherwise assets load from /assets/...
and break in production.
3. React Router basename
<BrowserRouter basename="/billing-app">
Without this:
- client-side navigation breaks
- refreshes 404
4. SPA fallback routing
Each SPA needs its own fallback:
try_files $uri /billing-app/index.html;
Otherwise deep routes fail.
The HTTPS Problem
One final issue surfaced during review.
The deployment was still running on:
http://<raw-ip>
But production cookies were marked:
Secure
Browsers reject secure cookies over plain HTTP.
Which means:
- cookie auth silently fails
- even before any SameSite logic matters
So before the final auth migration:
- HTTPS termination needs to exist
- preferably with a proper domain
- not a raw IP
Final Direction
After walking through all of this, the architecture direction became much clearer:
- Best End-State
- single gateway
- path-based routing
- same-origin frontend/API
- SameSite=Strict
- cookie-only auth
- no localStorage bearer tokens
That gives:
- simpler auth
- lower CSRF exposure
- fewer CORS headaches
- cleaner frontend architecture
Biggest Lesson
The most valuable part of this debugging session wasn’t a code change.
It was realizing that:
browser security behavior is deeply tied to deployment topology.
Two systems with identical backend code can behave completely differently depending on:
- domains,
- ports,
- proxies,
- and what the browser actually sees.
That understanding made the rest of the migration decisions much more obvious.
Top comments (0)