Bearer Tokens Across Origins

The same abc.com → dfg.com scenario, solved with tokens: what gets easier, what you take on instead.

Swap the cookie for a bearer token and the cross-site problem largely disappears — because no cookie is involved, so none of the cookie rules apply.

What stops mattering

  • SameSite — irrelevant. There is no cookie.
  • Third-party cookie blocking — irrelevant. Safari and Firefox have nothing to block.
  • CSRF — structurally impossible. evil.com's JavaScript cannot read your token, so it cannot build an authenticated request. It can still send a request, but it arrives unauthenticated.
  • Access-Control-Allow-Credentials — not needed for the API calls themselves.

What still matters

  • CORS. Authorization is not a safelisted header, so every call preflights. The API must list authorization in Access-Control-Allow-Headers and allow the origin.
  • Storage. The token is now your responsibility. Memory for the access token; refresh token in an HttpOnly cookie or the platform keychain.
  • Refresh plumbing. Expiry detection, a single in-flight refresh, retry-once.

The refresh cookie is still cross-site

Here is the honest complication. If you keep the refresh token in a cookie on dfg.com while the frontend is abc.com, that cookie is still third-party — and still blocked in Safari. Three ways out:

  1. Put the refresh endpoint on the same site as the frontend (api.abc.com), even if the rest of the API is elsewhere.
  2. Return the refresh token in the response body and store it in the platform keychain — appropriate for mobile, weaker for browsers.
  3. Accept re-login when the tab closes: keep everything in memory, no persistence at all. Fine for high-security applications.

Why this is the common answer

For a public API called from origins you do not control, tokens are the only workable scheme — there is no allowlist you could write. And for your own SPA on a different domain, tokens fail in fewer places than cross-site cookies do.

Example

Example · javascript
// API on dfg.com — no credentials needed for token-authenticated calls
app.use(cors({
  origin: ['https://abc.com'],
  allowedHeaders: ['Content-Type', 'Authorization'],  // ← authorization is required
  exposedHeaders: ['X-Request-Id'],
  maxAge: 86400,
  // credentials: true only if the REFRESH endpoint uses a cookie
}));

// Frontend on abc.com — no credentials, no SameSite, no third-party cookies
await fetch('https://dfg.com/api/orders', {
  headers: { Authorization: `Bearer ${await getToken()}` },
});

When to use it

  • A public API serving thousands of unknown customer origins uses bearer tokens because an origin allowlist is impossible to maintain.
  • A team migrates a cross-site cookie SPA to tokens after Safari users report being logged out on every page load.
  • A high-security console keeps tokens in memory only, so closing the tab genuinely ends the session with no persistence to steal.

More examples

The complete cross-origin token client

Splitting the two concerns is the point: API calls are pure bearer and work everywhere, while exactly one endpoint carries the cookie complexity.

Example · javascript
const API = 'https://dfg.com';

let accessToken = null;
let refreshing = null;

// The refresh endpoint is the ONLY call that uses credentials, because the
// refresh token lives in an HttpOnly cookie on dfg.com.
async function refresh() {
  const res = await fetch(`${API}/auth/refresh`, {
    method: 'POST',
    credentials: 'include',
  });
  if (!res.ok) { accessToken = null; throw new Error('session_expired'); }
  ({ accessToken } = await res.json());
  return accessToken;
}

export async function api(path, options = {}) {
  const token = accessToken ?? await (refreshing ??= refresh().finally(() => refreshing = null));

  const send = (t) => fetch(API + path, {
    ...options,
    // No credentials here: the token is enough, and omitting cookies keeps
    // these calls immune to third-party cookie policy entirely.
    headers: { 'Content-Type': 'application/json', ...options.headers,
               Authorization: `Bearer ${t}` },
  });

  let res = await send(token);
  if (res.status === 401) {
    const fresh = await (refreshing ??= refresh().finally(() => refreshing = null));
    res = await send(fresh);
  }
  if (!res.ok) throw Object.assign(new Error('api_error'), { status: res.status });
  return res.status === 204 ? null : res.json();
}

Why CSRF cannot happen here

This is the strongest argument for tokens in a cross-site setup: an entire vulnerability class stops applying, rather than being defended against.

Example · html
<!-- Hosted on evil.com, with the victim logged into abc.com -->
<script>
// Attempt 1: forge a request. It goes out — with no Authorization header,
// because evil.com's script has no access to abc.com's memory.
fetch('https://dfg.com/api/transfer', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ to: 'attacker', amount: 5000 }),
});
// → 401. The API never learns who the victim is.

// Attempt 2: send cookies too. There is no session cookie to send, and even
// if there were, the API does not authenticate by cookie.
fetch('https://dfg.com/api/transfer', { method: 'POST', credentials: 'include' });
// → 401.

// Attempt 3: read the token out of the victim's tab.
// Blocked by the same-origin policy — evil.com cannot touch abc.com's JS scope.
</script>

<!-- The CSRF class of attack requires an AMBIENT credential. Bearer tokens
     are not ambient: your code attaches them, so only your code can. -->

Discussion

  • Be the first to comment on this lesson.