Which One Should You Ship?
Both apps are built and working. Here is how to decide between them, and what to do first.
Two working implementations of the same product. The differences are now concrete rather than theoretical.
What we actually observed
| Cookie version | Token version | |
|---|---|---|
| Frontend auth code | ~10 lines | ~60 lines |
| Backend auth code | session store + CSRF | signing + refresh table |
| Works in Safari | no | yes |
| Works with 3rd-party cookies off | no | yes |
| Survives page reload | free | one refresh call |
| CSRF middleware needed | yes | no |
| XSS steals a durable credential | no (HttpOnly) | no (memory only) |
| Logout is instant | yes | within 10 minutes |
| Mobile client later | awkward | already works |
The recommendation
- First, try to stop being cross-site. Move the API to
api.abc.com, or proxy it atabc.com/api. Then run the cookie version withSameSite=Lax: least code, no browser roulette, instant revocation. This is the right answer far more often than teams expect. - If the sites must differ, ship the token version. It costs more client code and buys reliability you cannot get any other way.
- If you run a frontend server, consider the BFF. Tokens stay server-side entirely and the browser only ever holds a first-party cookie.
- Ship the cross-site cookie version only when forced — and add
Partitioned, and test in Safari before you commit.
What both versions must have
Whichever credential you choose, none of this is optional: TLS everywhere, Argon2 or bcrypt password hashing, rate limits on the auth routes, ownership checks inside every query, generic error messages that do not enumerate users, and no credentials in logs.
The authentication scheme is the smaller half of the problem. Most real incidents come from the authorization check that was never written, not from the choice between a cookie and a token.
Example
# The decision, in order
1. Can the API be same-site with the frontend?
abc.com + api.abc.com, or abc.com/api/* proxied
→ YES: cookie session, SameSite=Lax, CSRF token. Done.
2. Do you run a server for the frontend (Next.js, Laravel, nginx)?
→ YES: BFF. First-party cookie to the browser, token server-to-server.
3. Do mobile / CLI / partner clients call this API?
→ YES: bearer tokens.
4. Must it work in Safari and with third-party cookies disabled?
→ YES: bearer tokens.
5. Otherwise: cross-site cookies, with Partitioned, tested in Safari on day one.When to use it
- A team runs both versions, measures the failure rate in Safari, and uses that number rather than opinion to choose tokens.
- An architecture review moves the API to a subdomain and deletes the CSRF middleware, the SameSite=None config and the Partitioned workaround in one change.
- A product with a planned mobile app picks tokens up front, avoiding a second authentication scheme six months later.
More examples
Migrating from cookies to tokens without a flag day
Tagging each request with how it authenticated turns the last step from a guess into a dashboard reading — and it is three lines.
// Accept BOTH credentials during the migration. Old clients keep working;
// new clients use tokens. Remove the cookie branch once traffic is zero.
async function authenticate(req, res, next) {
// 1. Bearer first — new clients
const header = req.get('authorization') ?? '';
if (/^bearer /i.test(header)) {
try {
const claims = jwt.verify(header.slice(7), ACCESS_SECRET, {
algorithms: ['HS256'], issuer: ISSUER, audience: AUDIENCE,
});
req.user = { id: claims.sub, via: 'token' };
return next();
} catch {
return unauthorized(res, 'invalid_token');
}
}
// 2. Session cookie — old clients
const data = readSession(req.cookies[COOKIE]);
if (data) {
req.user = { id: data.userId, via: 'cookie' };
// CSRF protection still applies to THIS branch only.
return csrf(req, res, next);
}
return unauthorized(res, 'authentication_required');
}
// Instrument it, so "can we delete the cookie path?" has a data-backed answer.
app.use((req, res, next) => {
res.on('finish', () => {
if (req.user) metrics.increment('auth.request', { via: req.user.via });
});
next();
});
// Migration order that never leaves users signed out:
// 1. Add token issuance alongside sessions (this middleware)
// 2. Ship a frontend that prefers tokens, falls back to the cookie
// 3. Watch auth.request{via=cookie} fall to zero
// 4. Delete the cookie branch, the CSRF middleware and the session storeThe tests both versions should pass
The user-enumeration test is the one most suites lack, and it catches a real leak: different messages or noticeably different timings for unknown-user versus wrong-password.
// These assertions are scheme-independent. If either version fails one,
// it is not ready regardless of which credential it uses.
describe('authentication', () => {
it('rejects an unauthenticated request', async () => {
expect((await request(app).get('/api/notes')).status).toBe(401);
});
it('rejects a tampered credential', async () => {
const bad = validCredential.slice(0, -1) + 'X';
expect((await withCredential(request(app).get('/api/notes'), bad)).status).toBe(401);
});
it('does not reveal whether an email is registered', async () => {
const unknown = await login('[email protected]', 'whatever-password');
const wrongPw = await login('[email protected]', 'wrong-password');
expect(unknown.body).toEqual(wrongPw.body); // identical message
expect(unknown.status).toBe(wrongPw.status);
});
});
describe('authorization', () => {
it("does not return another user's notes", async () => {
const { body } = await withCredential(request(app).get('/api/notes'), aliceCred);
expect(body.notes.every((n) => n.userId === alice.id)).toBe(true);
});
it("returns 404 — not 403 — for another user's note", async () => {
const res = await withCredential(
request(app).delete(`/api/notes/${bobNote.id}`), aliceCred);
expect(res.status).toBe(404); // never confirm it exists
});
});
describe('lifecycle', () => {
it('invalidates the credential on logout', async () => {
const cred = await loginAs(alice);
await withCredential(request(app).post('/auth/logout'), cred);
// Cookie version: immediate. Token version: the refresh family is dead,
// so this holds as soon as the access token expires.
expect((await withCredential(request(app).get('/api/notes'), cred)).status)
.toBe(401);
});
it('rate limits repeated failures', async () => {
const results = [];
for (let i = 0; i < 25; i++) results.push(await login('[email protected]', 'wrong'));
expect(results.some((r) => r.status === 429)).toBe(true);
});
});
Discussion