FastAPI Cross-Site Cookies Silently Not Set: SameSite and Secure
Your FastAPI backend calls response.set_cookie(...). It returns 200 OK. The Set-Cookie header is right there in the response if you inspect it. And yet the browser never stores the cookie, so the very next request arrives with no session, and your login loops forever. There is no error. FastAPI is happy. The browser is silently dropping the cookie on the floor and telling nobody.
This is not a FastAPI bug. It is a browser policy decision, and it is invisible from the server side. set_cookie() has no idea whether the browser will accept what it sends, so it never warns you. The whole problem lives in three cookie attributes, SameSite, Secure and Domain, and how modern browsers treat them when your frontend and backend are on different origins.
a cookie sent from an API on one site to a frontend on a different site is a cross-site cookie. Browsers now default cookies to SameSite=Lax, which blocks that. To make it work you must set samesite="none" and secure=True, and because Secure cookies are dropped over plain HTTP, both frontend and backend must be on HTTPS, including in local dev. If you cannot do HTTPS locally, put the frontend and API behind one origin with a reverse proxy and the whole problem disappears.
The symptom: Set-Cookie is sent but the cookie never appears
You log in, the network tab shows the response header:
Set-Cookie: session=eyJhbGc...; HttpOnly; Path=/; SameSite=lax
But open DevTools, Application, Cookies, and it is not there. The next request to a protected endpoint returns your own 401, and you assume the token is wrong. It is not. The cookie was refused before it was ever stored. Chrome quietly logs the reason in the Issues tab (something like "this Set-Cookie was blocked because it had the SameSite=Lax attribute but came from a cross-site response"), but nothing surfaces in your app or your FastAPI logs.
Why the browser drops it: same-site vs cross-site
"Same-site" is not about being on the same server. It is about the registrable domain. A request from app.example.com to api.example.com is same-site (both under example.com). A request from myapp.vercel.app to api.myservice.com is cross-site, two different registrable domains. And a request from localhost:3000 to localhost:8000 is treated as same-site by port but the Secure/HTTPS rules still bite you, which is why local dev breaks in its own special way.
Since browsers made SameSite=Lax the default for cookies without an explicit attribute, a cross-site Set-Cookie with no SameSite is treated as Lax, and Lax cookies are not stored or sent on cross-site requests initiated by fetch/XHR. So your fetch-based login from a separate frontend domain gets a cookie the browser immediately discards. The only way to opt into cross-site cookies is SameSite=None, and the spec requires that None cookies also carry Secure. Secure, in turn, means the browser only accepts and sends the cookie over HTTPS. Plain http://localhost is exempt in current browsers for Secure in some cases, but cross-site SameSite=None over HTTP is not reliably allowed, which is exactly why "works in prod, broken in dev" (or the reverse) is so common.
Step 1: Set the cookie correctly for cross-site
Starlette's Response.set_cookie() (which FastAPI uses) takes these attributes directly. For a real cross-site setup:
from fastapi import Response
@app.post("/login")
def login(response: Response):
# ... verify credentials, build token ...
response.set_cookie(
key="session",
value=token,
httponly=True, # not readable by JS, blocks XSS token theft
secure=True, # required whenever samesite="none"
samesite="none", # opt in to cross-site sending
max_age=60 * 60 * 8, # 8 hours
path="/",
)
return {"ok": True}
Note samesite="none" is the lowercase string; passing secure=True alongside it is mandatory. Omit secure and modern browsers reject the whole cookie.
Step 2: Fix CORS so credentials are actually allowed
Cross-site cookies also require CORS to explicitly permit credentials, and you cannot use a wildcard origin with credentials. This is a separate gate from SameSite and both must pass:
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"], # explicit, never "*"
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
If you set allow_credentials=True together with allow_origins=["*"], Starlette will not send the credentialed CORS headers the browser needs, and the cookie is blocked at the CORS layer before SameSite even matters. I wrote more about why the wildcard fails in Blocked by CORS Policy: stop reaching for Access-Control-Allow-Origin: *.
Step 3: Make the frontend send credentials
The browser will not attach the cookie to a cross-site fetch unless you explicitly ask it to:
fetch("https://api.example.com/me", {
credentials: "include", // without this, the cookie is never sent
});
With axios, the equivalent is withCredentials: true. Miss this and everything above is correct but the cookie still never travels.
Step 4: Solve local development
Because Secure cookies need HTTPS, plain http://localhost dev with a separate API domain is the setup that breaks. Two honest options:
- Run local HTTPS. Use
mkcertto issue a locally trusted certificate forlocalhost, serve both frontend and API overhttps://, and the production config works unchanged. - Collapse to one origin (recommended). Proxy
/apito the backend from the same dev server so the browser sees a single origin. Then the cookie is same-site,SameSite=Laxis enough, and you can dropSecurein dev entirely.
// next.config.js - proxy /api to FastAPI so there is one origin
module.exports = {
async rewrites() {
return [{ source: "/api/:path*", destination: "http://localhost:8000/:path*" }];
},
};
Verification
Do not trust your app logic to tell you. Open DevTools, Application, Cookies, and check the cookie is listed under the correct domain with SameSite=None and Secure ticked. Then confirm it is actually sent on the next request:
# The follow-up request should carry the cookie back
curl -i --cookie "session=..." https://api.example.com/me
The definitive check is the browser Network tab: the second request's Request Headers must contain Cookie: session=.... If it does, you are done. If the response has Set-Cookie but the storage panel stays empty, the browser rejected it, open the Issues tab for the exact reason.
What people get wrong
"Add a longer sleep / retry, it is a timing issue." There is no timing issue. The cookie is rejected synchronously at the moment the response arrives. Retrying sends the same rejected cookie again.
"Set SameSite=None and move on." None without Secure is rejected outright by current browsers. They travel together or not at all.
"Just store the token in localStorage instead." That trades a cookie problem for an XSS problem, since any injected script can read localStorage. If you are tempted, read why localStorage tokens are an XSS liability and HttpOnly cookies are not. The right answer is to make the cookie work, not to abandon HttpOnly.
"Set Domain=.example.com to share it." That only helps for genuinely same-registrable-domain subdomains. It does nothing for two unrelated domains, and setting a Domain the browser considers cross-site gets the cookie rejected too.
When it is still broken
- Cookie appears but is not sent back. You forgot
credentials: "include"on the frontend, or your CORSallow_credentialsis false. - Works in Chrome, fails in Safari. Safari's Intelligent Tracking Prevention is more aggressive about third-party (cross-site) cookies. This is another reason the single-origin reverse-proxy approach is more robust than fighting per-browser policy.
- It works locally but not in production. Check that your production frontend origin exactly matches
allow_origins, scheme and all.https://app.example.comandhttps://www.app.example.comare different origins. - You are behind a proxy that strips headers. Some proxies drop
Set-Cookieor rewriteSecure. Confirm the header survives to the browser by inspecting the raw response, not the app.
The mental model to keep: FastAPI setting a cookie and the browser keeping it are two separate events, and the server never learns the outcome of the second one. When cookies "silently fail", stop reading server logs and go straight to the browser's Application and Issues panels. That is the only place the truth is written down.
Frequently asked questions
- Why is my FastAPI cookie not being set in the browser even though the response is 200?
- The browser is rejecting the cookie silently because it is cross-site. Modern browsers default cookies to SameSite=Lax, which blocks them on requests between different sites. To store a cross-site cookie you must set samesite='none' and secure=True in set_cookie, allow credentials in CORS, and serve both ends over HTTPS. FastAPI never sees the rejection, so check the browser's Application and Issues panels, not your server logs.
- What is the correct set_cookie call for a separate frontend and backend domain?
- response.set_cookie(key='session', value=token, httponly=True, secure=True, samesite='none', path='/'). The samesite='none' opts into cross-site sending and secure=True is mandatory alongside it. You must also set allow_credentials=True with an explicit (non-wildcard) origin in CORSMiddleware, and the frontend must fetch with credentials: 'include'.
- Why do cross-site cookies break in local development but work in production?
- Cross-site cookies require SameSite=None with Secure, and Secure cookies are only reliably accepted over HTTPS. Local dev on plain http://localhost cannot satisfy the Secure requirement for cross-site cookies. Either run local HTTPS with a tool like mkcert, or proxy your API under the same origin as the frontend so the cookie becomes same-site and Secure is no longer required.
- Should I just use localStorage instead of fixing the cookie?
- No. Storing auth tokens in localStorage exposes them to any cross-site scripting (XSS) attack, because injected JavaScript can read localStorage directly. An HttpOnly cookie cannot be read by JavaScript at all. Fixing the SameSite and Secure attributes is the correct solution; abandoning HttpOnly cookies trades a configuration problem for a security hole.