Cookie vs Bearer Across Sites: Side by Side

The same abc.com → dfg.com login implemented both ways, compared line by line, with a recommendation.

Both approaches work. They fail differently, and that is what should decide it.

Cross-site cookieBearer token
Cookie attributesSameSite=None; Secure requirednone needed
CORSexact origin + Allow-Credentialsexact origin + Authorization header
Client codecredentials: 'include'attach the header, handle refresh
Blocked by Safariyesno
Partitioned by Firefoxyesno
CSRF riskyes — needs tokens + Origin checksstructurally none
XSS riskcannot be read (HttpOnly)readable unless kept in memory
Revocationinstant (delete the session)within one token lifetime
Mobile / CLI clientsawkwardnatural
Survives page reloadfreeneeds a refresh call

The honest summary

Cross-site cookies are simpler to write and less reliable to run. Bearer tokens are more code and fewer surprises. The deciding factor is usually not security — both can be made secure — but the fact that one of them stops working in browsers you do not control.

What to actually do

  1. If you control both domains: stop being cross-site. Move the API to api.abc.com or proxy through abc.com/api. First-party cookies, no CORS credentials, no browser roulette. This is the best answer and it is usually a DNS change.
  2. If you cannot: use bearer tokens. Access token in memory, refresh token wherever it can be first-party, refresh on boot.
  3. If you need the strongest browser story: use a BFF. Tokens never reach the browser at all.
  4. Use cross-site cookies only when something forces them — a legacy client, a specific embed requirement — and then add Partitioned and test in Safari on day one.

One thing both need

Neither scheme replaces authorization. Ownership checks, scope checks and rate limits apply identically whichever credential arrives.

Example

Example · bash
# The same login, both ways

# --- Cross-site cookie ---
POST https://dfg.com/login          credentials: 'include'
  ← Set-Cookie: sid=…; SameSite=None; Secure
GET  https://dfg.com/api/orders     credentials: 'include'
  → works in Chrome, fails in Safari, partitioned in Firefox

# --- Bearer token ---
POST https://dfg.com/auth/login
  ← { accessToken, expiresIn: 600 }   (+ refresh cookie, if first-party)
GET  https://dfg.com/api/orders      Authorization: Bearer …
  → works everywhere

# --- Same-site (what you should aim for) ---
POST https://api.abc.com/login
  ← Set-Cookie: sid=…; SameSite=Lax; Secure
GET  https://api.abc.com/orders      credentials: 'include'
  → works everywhere, and it is first-party

When to use it

  • A team compares both implementations in staging, finds the cookie version broken in Safari, and ships the token version.
  • An architecture review moves the API to a subdomain of the frontend and deletes several hundred lines of CORS and SameSite workarounds.
  • A product that must embed in third-party sites chooses tokens because it cannot control which browsers its host sites' visitors use.

More examples

The two servers, minimal and complete

Option B still has one cross-site cookie for refresh. Moving just that endpoint to api.abc.com removes the last third-party cookie in the system.

Example · javascript
// ============ Option A: cross-site cookie ============
app.use(cors({ origin: ['https://abc.com'], credentials: true }));

app.post('/login', async (req, res) => {
  const user = await verifyCredentials(req.body);
  if (!user) return res.status(401).json({ error: 'invalid_credentials' });
  res.cookie('sid', await createSession(user.id, req), {
    httpOnly: true, secure: true, sameSite: 'none',   // ← cross-site
    partitioned: true,                                 // ← CHIPS
    path: '/', maxAge: 30 * 60e3,
  });
  res.json({ user: publicProfile(user) });
});
app.use('/api', csrf, sessionAuth);     // CSRF protection is REQUIRED here

// ============ Option B: bearer token ============
app.use(cors({
  origin: ['https://abc.com'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,                    // only for /auth/refresh
}));

app.post('/auth/login', async (req, res) => {
  const user = await verifyCredentials(req.body);
  if (!user) return res.status(401).json({ error: 'invalid_credentials' });
  res.cookie('rt', await issueRefreshToken(user.id), {
    httpOnly: true, secure: true, sameSite: 'none', partitioned: true,
    path: '/auth/refresh', maxAge: 30 * 24 * 3600e3,
  });
  res.json({ accessToken: issueAccessToken(user), expiresIn: 600 });
});
app.use('/api', bearerAuth);            // no CSRF needed — nothing is ambient

Pick by answering four questions

Question 1 resolves the majority of real cases, and it is usually a DNS change plus a cookie attribute — far cheaper than the code the other branches require.

Example · bash
1. Can the API live on a subdomain of the frontend, or behind its path?
   YES → do that. Same-site cookies. Stop here; nothing below applies.
   NO  → continue.

2. Do you run a server for the frontend (Next.js, Remix, Nuxt, nginx)?
   YES → BFF pattern. No token in the browser at all.
   NO  → continue.

3. Do non-browser clients (mobile, CLI, partners) call this API?
   YES → bearer tokens. Cookies are the wrong shape for them anyway.
   NO  → continue.

4. Must it work in Safari and with third-party cookies disabled?
   YES → bearer tokens.
   NO  → cross-site cookies are acceptable; add Partitioned and test early.

# Almost every path leads away from cross-site cookies. That is the lesson.

Discussion

  • Be the first to comment on this lesson.