DEV Community

Cover image for Build Log: Untangling SameSite, Same-Origin, and Cookie Auth in a Microservice Platform
Thuyavan
Thuyavan

Posted on

Build Log: Untangling SameSite, Same-Origin, and Cookie Auth in a Microservice Platform

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")}
Enter fullscreen mode Exit fullscreen mode

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:

  1. switch cookies to SameSite=None; Secure
  2. move frontend to cookie auth
  3. remove bearer-token usage
  4. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/*

Enter fullscreen mode Exit fullscreen mode

Internally nginx fans requests out to different services:

auth:3001
billing:3009
notifications:3010
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This becomes:

  • cross-origin
  • but same-site

In this setup:

SameSite=Strict still works
but CORS credentials become mandatory

Example:

fetch(url, {
  credentials: "include"
})
Enter fullscreen mode Exit fullscreen mode

and server-side:


credentials: true
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

This introduces several subtle problems.

1. Prefix collisions

If:


/billing/
Enter fullscreen mode Exit fullscreen mode

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/"
Enter fullscreen mode Exit fullscreen mode

Otherwise assets load from /assets/...
and break in production.

3. React Router basename
<BrowserRouter basename="/billing-app">
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Otherwise deep routes fail.


The HTTPS Problem

One final issue surfaced during review.

The deployment was still running on:


http://<raw-ip>
Enter fullscreen mode Exit fullscreen mode

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)