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 cookie | Bearer token | |
|---|---|---|
| Cookie attributes | SameSite=None; Secure required | none needed |
| CORS | exact origin + Allow-Credentials | exact origin + Authorization header |
| Client code | credentials: 'include' | attach the header, handle refresh |
| Blocked by Safari | yes | no |
| Partitioned by Firefox | yes | no |
| CSRF risk | yes — needs tokens + Origin checks | structurally none |
| XSS risk | cannot be read (HttpOnly) | readable unless kept in memory |
| Revocation | instant (delete the session) | within one token lifetime |
| Mobile / CLI clients | awkward | natural |
| Survives page reload | free | needs 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
- If you control both domains: stop being cross-site. Move the API to
api.abc.comor proxy throughabc.com/api. First-party cookies, no CORS credentials, no browser roulette. This is the best answer and it is usually a DNS change. - If you cannot: use bearer tokens. Access token in memory, refresh token wherever it can be first-party, refresh on boot.
- If you need the strongest browser story: use a BFF. Tokens never reach the browser at all.
- Use cross-site cookies only when something forces them — a legacy client, a specific embed requirement — and then add
Partitionedand 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
# 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-partyWhen 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.
// ============ 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 ambientPick 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.
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