Tutorial

Cross-Origin Cookies: Why Your Login Works But Every Request After It Fails

Login returns 200 but every request after it gets 401? Here's why cross-origin cookies fail and the exact fix for frontend and backend.

You log in. The network tab shows a 200. The backend logs say Set-Cookie. And then the very next request — the one that's supposed to prove you're authenticated — comes back 401, or the cookie never shows up in the request headers at all.

This is almost always not a bug in your code. It's the browser doing exactly what it's designed to do when a frontend on one origin talks to a backend on another, and nobody told the stack how to handle that honestly.

The direct answer: a cookie set by a cross-origin backend fails to persist or get sent back for one of four reasons — the browser blocked the response before the cookie attribute, the cookie's SameSite/Secure combination doesn't permit cross-site sending, the frontend's fetch call didn't ask to include credentials, or the CORS response itself was malformed (wrong origin echo, missing credentials flag, or headers stripped by middleware ordering). Fix those four things correctly — not by disabling CORS or CSRF, but by configuring them for your actual topology — and cross-origin cookie auth becomes boring, which is what you want from auth.

This article treats CORS, cookies, and CSRF as one system, because they are. Get the mental model right and the configuration follows. Get it wrong and you'll keep "fixing" preflight by loosening security until something works, which is how allow_origins=["*"] with credentials ends up in production — a combination that should never ship.

Before touching code, it helps to separate what each layer is actually responsible for, because developers conflate them constantly.

  • The browser's same-origin policy decides whether JavaScript running on app.example.com is allowed to read the response from a request to api.example.com.
  • CORS is the mechanism by which a server tells the browser "this origin is allowed to see my response." Without the right headers, the browser still lets the request happen in many cases — but hides the response from your JavaScript.
  • Cookie attributes (Secure, HttpOnly, SameSite, Domain) decide whether the browser will store a cookie at all, and whether it will attach that cookie to a subsequent request — independent of CORS entirely.
  • CSRF protection is a separate concern from both: it stops an attacker's site from piggybacking on a cookie your browser will send automatically, even when CORS and cookie attributes are configured correctly.

These interact, but they are not the same mechanism, and most "CORS isn't working" threads are actually a cookie-attribute or middleware-ordering problem wearing a CORS costume.

CORS Fundamentals: What Preflight Actually Checks

When your Next.js app issues a fetch to a different origin with a non-trivial method, custom headers, or credentials, the browser first sends an OPTIONS request — the preflight — before your real request ever leaves the client.

The server has to answer that preflight with headers that explicitly grant permission: which origin may call it (Access-Control-Allow-Origin), which HTTP methods are allowed (Access-Control-Allow-Methods), and which request headers are allowed (Access-Control-Allow-Headers). If any of those are missing, wrong, or the server errors before producing them, the browser throws the preflight away and your real request never fires.

This is the single most common failure mode reported by developers: a response arrives, but it's missing the CORS header the browser needed, and the browser reports something to the effect of no Access-Control-Allow-Origin header being present on the requested resource. It reads like a server problem because, functionally, it is — the fix lives in your backend's CORS configuration, not in frontend retry logic.

Configuring CORSMiddleware in FastAPI

FastAPI (via Starlette) ships CORSMiddleware specifically to answer these preflight checks and attach the right headers to every response. A baseline configuration for a split-origin Next.js + FastAPI stack looks like this:

from fastapi import FastAPI
from starlette.middleware.cors import CORSMiddleware

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://app.my-domain.com"],
    allow_credentials=True,
    allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
    allow_headers=["Content-Type", "Authorization", "X-CSRF-Token"],
)

A few judgment calls worth being explicit about:

  • List exact origins, never a wildcard, when credentials are involved. allow_origins=["*"] paired with allow_credentials=True is a combination the browser itself won't honor for credentialed requests, and even where it technically applied, trusting any origin with cookie-bearing requests defeats the purpose of having CORS at all.
  • allow_credentials=Trueis mandatory if you want the browser to send or store cookies on cross-origin requests — without it, Set-Cookie on the response is simply ignored by the fetch client for CORS purposes.
  • Scope allow_methods and allow_headers to what you actually use. It's tempting to allow everything during development and forget to tighten it later — don't.

FastAPI's own security posture here is opt-in, not opt-out: nothing about a fresh FastAPI app assumes you want permissive cross-origin behavior, strict transport, or authentication wired up — you configure all of it deliberately. Treat an overly broad CORS policy as a real attack surface, not a convenience setting: it's repeatedly flagged as one of the more common ways FastAPI services leak more access than intended.

Middleware Ordering: The Bug That Looks Like a CORS Bug

Here's the failure mode that burns experienced engineers specifically because it doesn't look like a CORS misconfiguration at first: your CORS settings are correct, preflight passes, but authenticated requests that fail validation — a bad token, an expired session — come back as a raw CORS error in the browser console instead of the 401 your server actually sent.

This happens when custom middleware (auth checks, logging, exception handlers) sits in front of CORSMiddleware in the stack, intercepting the request or the error before CORSMiddleware gets a chance to attach its headers to the outgoing response. The server did respond — with a perfectly reasonable 401 or HTTPException — but because the CORS headers never made it onto that particular response, the browser can't tell your JavaScript what happened and reports a CORS failure instead. Developers have hit this exact scenario and reported it against FastAPI directly: an error response generated inside custom middleware shows up client-side as a CORS error rather than the actual status code.

The practical fix is ordering: register CORSMiddleware so that it wraps everything else, meaning it should typically be added first, since Starlette/FastAPI applies middleware in a way where the first one added ends up outermost. If you have custom exception-handling middleware, make sure it's wrapped by CORS, not the other way around, so that every response — success or error — passes through the CORS layer before reaching the browser.

A quick way to tell which bug you actually have: open the network tab, not just the console. If you see a real HTTP status code with a response body, but the browser still reports a CORS error, it's a middleware-ordering/header problem, not a policy problem — your allow_origins is probably fine.

Next.js-Side Handling: Fetch, Credentials, and the Proxy Alternative

Next.js enforces the same browser-level same-origin restrictions as any other frontend — there's nothing special about the framework that bypasses them, so a Next.js app calling a separately-hosted FastAPI backend is a genuine cross-origin request subject to all of the above.

Two viable architectures exist, and picking the wrong one for your deployment causes needless pain:

Option A — direct cross-origin fetch. The browser calls the FastAPI origin directly. This requires the full CORS + cookie configuration this article covers, and the client-side fetch must explicitly opt in to sending credentials:

const res = await fetch("https://api.my-domain.com/me", {
  method: "GET",
  credentials: "include",
});

Without credentials: "include" (or the equivalent withCredentials: true on XMLHttpRequest/axios), the browser will not attach cookies to a cross-origin request even if everything else is configured correctly — this is a separate opt-in from CORS itself and it's the detail most commonly missed.

Option B — proxy through Next.js. Route API calls through Next.js rewrites or an API route that forwards to FastAPI server-side. From the browser's perspective, everything is same-origin, so cookie and CORS concerns mostly disappear on the client, at the cost of an extra network hop and some complexity in forwarding Set-Cookie correctly.

I lean toward Option A for stacks where the frontend and backend are genuinely meant to be called independently (mobile clients, third-party integrations later), and Option B when the API only ever serves that one Next.js app and you'd rather not think about cross-origin cookies at all.

This is where most of the "it works in Postman but not the browser" confusion lives. Three cookie attributes govern cross-origin behavior, and they do it independently of your CORS configuration:

Attribute Purpose in cross-origin auth
HttpOnly Blocks JavaScript from reading the cookie, reducing the damage an XSS bug can do to session tokens.
Secure Cookie is only sent to the server over HTTPS (localhost excepted).
SameSite Controls whether the browser attaches the cookie to a request initiated from a different site.

SameSiteis the one that directly decides whether your cross-origin login works at all:

  • Strict— the cookie is withheld on essentially any cross-site navigation or request. Fine for a same-origin monolith, unworkable for a split frontend/backend.
  • Lax— sent on top-level navigations using safe methods like GET, but not on cross-site subrequests such as fetch/XHR — which is exactly the kind of request your Next.js frontend makes to your FastAPI backend.
  • None— the cookie is sent on cross-site requests, but browsers require it to be paired with Secure, meaning it only works over HTTPS.

If your Next.js frontend and FastAPI backend live on genuinely different registrable domains — not subdomains of the same domain — SameSite=None; Secure is the only setting that lets the auth cookie travel with the request at all. There's no way around this requirement; it's a hard browser rule, not a server preference.

Cross-Subdomain vs. Cross-Domain: Pick the Right Fix for Your Topology

This distinction changes your entire configuration, so get your topology straight first.

Same parent domain, different subdomains — e.g., a frontend at app.my-domain.com and an API at api.my-domain.com. A cookie set by the API with a default, host-only scope will not be visible to the frontend's requests; it has to be explicitly scoped to the shared parent domain, something like .my-domain.com, for the browser to treat it as shared across both subdomains. In this topology, using server-verified session cookies rather than JWTs is the recommended approach for a frontend and backend that function as one logical application, since the cookie mechanism already handles transport and the server retains the ability to invalidate sessions directly.

Genuinely different domains — e.g., my-saas.com and a backend on my-saas-api.io, or a third-party API consumed from multiple unrelated frontends. There is no parent domain to scope a cookie to, so you're fully dependent on SameSite=None; Secure plus a correctly configured CORS policy with allow_credentials=True and an explicit origin allow-list. This is a strictly harder environment to secure, and it's worth asking honestly whether you can instead put both under one parent domain — it removes an entire category of cookie and CSRF edge cases.

A Bearer token in an Authorization header sidesteps the browser's cookie rules entirely — no SameSite, no cookie Domain scoping, because the client attaches it explicitly rather than the browser doing it automatically. That's genuinely convenient for cross-domain architectures and for non-browser clients.

But a JWT in a header doesn't make CSRF protection unnecessary by itself; it sidesteps the cookie-based CSRF attack vector specifically because the browser never auto-attaches an Authorization header the way it auto-attaches cookies — the token verifies identity, it doesn't address forged-request risk on its own, and if you ever fall back to storing that token in a cookie for convenience, you've reintroduced the exact problem it was avoiding.

For the "one logical application" shape most split Next.js + FastAPI stacks actually have, server-verified session cookies are the better default — you get HttpOnly protection against token theft via XSS, straightforward server-side revocation, and you're explicitly supported by guidance recommending this pattern over JWTs in that scenario. Reach for JWTs when you have genuinely stateless, multi-client, or third-party-consumed APIs where no shared cookie story exists.

CSRF: Why It's Still Your Problem With Cookies

CSRF works by exploiting the fact that the browser attaches cookies to requests automatically, regardless of which site initiated the request — so a malicious page can trigger a request to your API, and the browser will happily include the victim's session cookie along with it.

The standard defense is the double-submit pattern: the server issues a CSRF token, the client reads it and resends it in a custom header (commonly X-CSRF-Token) on state-changing requests, and the server rejects the request unless the header value matches the token it issued. Because that header has to be set explicitly by your frontend's JavaScript, an attacker's forged request — which can trigger the browser to send cookies but can't read or set your custom headers across origins — fails the check.

When you can relax this: SameSite=Strict removes most CSRF risk by itself for same-site requests, and SameSite=Lax closes off the most dangerous cross-site GET/navigation vectors, though it still doesn't fully protect state-changing requests triggered by non-navigation cross-site activity in every case — don't treat Lax as a full substitute for CSRF tokens on sensitive mutating endpoints. If your auth is purely Authorization: Bearer header-based with no cookie involved anywhere in the request lifecycle, classic cookie-driven CSRF doesn't apply — but the moment any part of your flow sets an auth-relevant cookie, CSRF protection belongs back on the table.

FastAPI Auth Dependencies and General Hardening

FastAPI's dependency-injection model (Depends) is the natural place to enforce auth consistently: an OAuth2PasswordBearer or custom cookie-reading dependency can validate the session or token once and inject the current user into every route that needs it, instead of re-implementing the check per-endpoint. Pair that with proper password hashing for any credential storage, and Pydantic validation on every input boundary, so malformed requests fail before they reach business logic.

None of this matters without transport security: secure cookies are only sent over HTTPS (browsers treat localhost as an exception), and SameSite=None requires Secure, so serve both your frontend and API over HTTPS. Treat HTTPS everywhere — frontend, backend, and any proxy in between — as a non-negotiable baseline, not a production afterthought.

A Working Checklist

  1. Identify your topology first: shared parent domain vs. fully separate domains — this decision gates everything else.
  2. Put CORSMiddleware outermost in your FastAPI middleware stack, with an explicit origin list and allow_credentials=True.
  3. Scope the session cookie's Domain to the shared parent if you're on subdomains; otherwise use SameSite=None; Secure.
  4. Set HttpOnly and Secure on every auth cookie, no exceptions.
  5. Add credentials: "include" to every cross-origin fetch call from Next.js.
  6. Implement a CSRF token + header check on state-changing routes unless your auth is strictly header-based with zero cookies involved.
  7. Serve everything over HTTPS before you worry about any of the above.

Frequently asked questions

Why does my cookie show up in the FastAPI response but never appears in the browser's storage?

Usually SameSite and Secure are mismatched for your topology — a cross-site cookie without SameSite=None; Secure will be silently dropped by the browser, or the cookie's Domain doesn't cover the frontend's subdomain.

Can I just set allow_origins=["*"] while I'm developing and fix it later?

Don't build credentialed flows against a wildcard origin even temporarily — browsers won't honor it for credentialed requests anyway, and it trains your config toward the exact insecure default you don't want shipping.

Is CSRF protection redundant if I'm already using HTTPS and HttpOnly cookies?

No — HTTPS protects data in transit and HttpOnly protects against script-based theft, but neither stops a forged cross-site request from riding along on a browser-attached cookie; that's specifically what CSRF tokens are for.

Does switching to JWT in an Authorization header eliminate my CORS and cookie problems?

No. A cross-origin request with an Authorization header still triggers a CORS preflight, so the server must still return correct CORS headers (allowed origin, Authorization listed in Access-Control-Allow-Headers). You only avoid cookie-attribute concerns like SameSite as long as the token isn't stored in a cookie.