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 versionToken version
Frontend auth code~10 lines~60 lines
Backend auth codesession store + CSRFsigning + refresh table
Works in Safarinoyes
Works with 3rd-party cookies offnoyes
Survives page reloadfreeone refresh call
CSRF middleware neededyesno
XSS steals a durable credentialno (HttpOnly)no (memory only)
Logout is instantyeswithin 10 minutes
Mobile client laterawkwardalready works

The recommendation

  1. First, try to stop being cross-site. Move the API to api.abc.com, or proxy it at abc.com/api. Then run the cookie version with SameSite=Lax: least code, no browser roulette, instant revocation. This is the right answer far more often than teams expect.
  2. If the sites must differ, ship the token version. It costs more client code and buys reliability you cannot get any other way.
  3. If you run a frontend server, consider the BFF. Tokens stay server-side entirely and the browser only ever holds a first-party cookie.
  4. 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

Example · bash
# 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.

Example · javascript
// 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 store

The 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.

Example · javascript
// 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

  • Be the first to comment on this lesson.