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.
Authorizationis not a safelisted header, so every call preflights. The API must listauthorizationinAccess-Control-Allow-Headersand allow the origin. - Storage. The token is now your responsibility. Memory for the access token; refresh token in an
HttpOnlycookie 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:
- Put the refresh endpoint on the same site as the frontend (
api.abc.com), even if the rest of the API is elsewhere. - Return the refresh token in the response body and store it in the platform keychain — appropriate for mobile, weaker for browsers.
- 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
// 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.
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.
<!-- 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