Cookies and CSRF
A bearer-token API with no cookies needs no CSRF protection, because a browser never attaches an Authorization header by itself. The moment a credential lives in a cookie, the browser sends it on every request to your domain, including the ones a hostile page makes, and then you do need it. This page is how to decide, and how to do it safely if you must.
The framework has no cookie support of its own (no parsing, no sessions, no CSRF tokens), and AuthGuard only reads Authorization and x-api-key, never a cookie. What is here is a small, tested recipe you copy: packages/security/src/cookies.example.ts and csrf.example.ts.
1. Decide where the credential lives
Section titled “1. Decide where the credential lives”| Where | CSRF | Script (XSS) | Use it when |
|---|---|---|---|
Access token in memory, sent as Authorization: Bearer (what examples/saas-api expects) |
Not applicable: nothing is sent automatically. | An XSS can use the token while the page is open. Nothing is left behind once it closes. | A single-page app or a mobile client. The default here. |
Access token in memory, refresh token in an HttpOnly cookie limited to the refresh route |
Only the refresh and sign-out routes are exposed. Protect those. | A script cannot read the refresh token, which is the long-lived one. | A browser app that must survive a reload without asking for a password. |
| The access token itself in a cookie, turned into a bearer by a middleware | Every route is exposed. originCheck is mandatory. |
A script cannot read it. | A server-rendered or same-origin app that wants no token handling in JavaScript. |
Token in localStorage |
Not applicable. | Any XSS reads it and keeps it. | Avoid. |
saas-api today returns its refresh token in the JSON body, so it uses the first row and has no cookies. The rest of this page is for the second and third rows.
2. Set and read cookies safely
Section titled “2. Set and read cookies safely”import { clearCookie, parseCookies, serializeCookie } from "./cookies.js"; // copied from cookies.example.ts
// at sign-in: a raw Response is the way to set a header, and it skips response validationconst headers = new Headers({ "content-type": "application/json" });headers.append("set-cookie", serializeCookie("__Host-access", accessToken, { maxAge: 900, sameSite: "Strict" }));headers.append("set-cookie", serializeCookie("__Secure-refresh", refreshToken, { maxAge: 2_592_000, path: "/auth", sameSite: "Strict" }));return new Response(JSON.stringify({ ok: true }), { headers });
// in the refresh routeconst refreshToken = parseCookies(request.headers.get("cookie")).get("__Secure-refresh");
// at sign-out: the same name and attributes, an empty value, Max-Age=0headers.append("set-cookie", clearCookie("__Secure-refresh", { path: "/auth", sameSite: "Strict" }));append, neverset, forSet-Cookie.setreplaces the cookies already on the response.withResponseHeadersfrom@blixis-io/httpusesset, so do not use it for cookies. The Node adapter sends eachSet-Cookieas its own header (checked with a real socket: two cookies arrive as two).serializeCookiepercent-encodes the value, so a value can never carry a;or a line break into the header, and refuses a name, path or domain that could. Its defaults are the safe ones:Secure,HttpOnly,SameSite=Lax,Path=/.parseCookiesskips what is malformed and lets the first cookie of a name win, so a sibling subdomain that plants a secondsidcannot replace yours.
Which attributes, and why
Section titled “Which attributes, and why”- The
__Host-prefix for anything whose loss matters. The browser then refuses the cookie unless it isSecure, hasPath=/and has noDomain, which stops a sibling subdomain from overwriting it.serializeCookierefuses to write a__Host-cookie that breaks those rules. A cookie limited to a path (the refresh cookie on/auth) cannot be__Host-; use__Secure-and a path. HttpOnlyso script cannot read it.Secureso it is not sent in clear. Both are defaults.SameSite=Strictfor the credential cookies of an app that does not need to log a user in from a link on another site;Lax(the default) sends the cookie on top-level navigations from other sites, which is what lets a bookmarked or emailed link arrive logged in.SameSiteis a second layer: do not rely on it alone, because it is about site, not origin, and a sibling subdomain is the same site.- Never
Domainunless subdomains must share the cookie. It widens who can read it and who can overwrite it. Max-Agethe same as the token’s life. A cookie that outlives its token only produces401s.- Browsers silently drop a cookie over 4096 bytes;
serializeCookiethrows instead.
3. Refuse cross-site requests, without tokens
Section titled “3. Refuse cross-site requests, without tokens”createHttpApplication(AppModule, { middleware: [ health.middleware, requestId(), accessLog({ /* ... */ }), cors({ origins: ["https://app.example.com"], credentials: true }), // explicit origins; "*" with credentials is refused securityHeaders(), originCheck({ trustedOrigins: ["https://app.example.com"] }), // before anything that turns a cookie into a credential cookieToBearer({ cookie: "__Host-access" }), // only for the third row of the table rateLimit({ /* ... */ }), ],});originCheck (from csrf.example.ts) uses what the browser itself reports, in headers a page’s script cannot set, so there is no token to issue, store or forget:
GET,HEADandOPTIONSpass. They must not change anything, or nothing here protects it.OriginintrustedOrigins: passes (a separate front end on its own domain).Sec-Fetch-Site: same-originornone(a typed address, a bookmark): passes.same-siteandcross-siteare refused unless the origin is trusted: a sibling subdomain can belong to someone else.- Neither header: not a browser, or too old to say. It passes, because CSRF abuses the cookies a browser attaches by itself.
- An older browser (an
Origin, noSec-Fetch-Site): the origin’s host must equal the request’sHost. Behind a proxy that rewritesHost, list your public origin intrustedOrigins.
Everything else is a 403, and the application never runs. This includes the sign-in POST, which closes “login CSRF”, where an attacker signs the victim into the attacker’s account.
cookieToBearer is what lets the rest of the application keep reading only Authorization: with no Authorization header and the named cookie present, the cookie’s value becomes the bearer token and the normal guard verifies it. An explicit Authorization header always wins. It exists to be used after originCheck, and a test checks that order: a cross-site request carrying the cookie is refused before it becomes a credential.
Why not CSRF tokens
Section titled “Why not CSRF tokens”Tokens (a synchronizer token, or a double-submit cookie) work and are the answer when you must support browsers that send neither header. They need state or a cookie to manage, a way to hand the token to the page, and discipline on every form and request. The header check needs none of that and fails closed. If you need tokens as well, add them in front of the same routes; this recipe does not issue them.
4. Things that still go wrong
Section titled “4. Things that still go wrong”- XSS defeats all of this. A script running on your page can make same-origin requests, which
originCheckcorrectly allows.HttpOnlystops it reading the cookie, not using it. KeepsecurityHeaders()(with a realContent-Security-Policyfor a page that serves HTML) and escape output. - State-changing
GET.GET /sign-outorGET /delete?id=1is open to CSRF whatever you do here. UsePOSTorDELETE. - CORS is not CSRF protection. CORS stops a hostile page reading a response; a “simple”
POSTis still sent, and its effect still happens. That is whatoriginCheckis for. cors({ credentials: true })with"*"is refused when the middleware is created, and echoing back any origin would let any site make authenticated requests as your user. List the origins.- Do not log
CookieorSet-Cookie.accessLogrecords method, path, status and duration only; check that your ownonErrorand logger do not add request headers. - Subdomains. A cookie on a parent
Domain, or any untrusted subdomain you host (user pages, a forgotten staging host), reopens what__Host-and the same-site refusal close.
What is tested, and what is not
Section titled “What is tested, and what is not”Tested (packages/security, 57 tests) with the real application and a real socket, and by fuzzing: every row of the originCheck rules (same-site, cross-site, opaque null origin, unparseable origin, same host on another port, trusted origin by exact match only, a lookalike domain), the bridge never overriding an explicit Authorization header, the order of the two middleware, two Set-Cookie headers surviving the Node adapter as two headers, and, for the helpers, that any string round-trips and that no value can add an attribute or a header. Each rule was checked by removing the line that provides it and watching a test fail.
Not tested: a real browser, so the Sec-Fetch-Site and Origin headers are set by hand the way a browser documents setting them; older browsers and Safari’s handling of SameSite; a proxy that rewrites Host; and the bridge together with AuthGuard (the bridge is tested to deliver the right Authorization header, and AuthGuard is tested to accept one, but not in the same request).