CSRF in 2026: Why DaloyJS Ships Both Double-Submit and Fetch-Metadata
A short history of the double-submit cookie, the case for tokenless protection via Sec-Fetch-Site, when each one fails, and why strategy: "both" is the realistic default for apps that still have to serve a 2018 mobile browser somewhere.
Hi, I'm Devlin. Ten years of fullstack work. I have, at some point, written every CSRF bug a person can write. The classic <img src="https://your-bank/transfer?amount=..."> from the early 2010s. The "we forgot to send the token from the new mobile app" from the mid 2010s. The "we set SameSite=None for the iframe and then forgot to set Secure" one from a year I don't want to name. So when the team sat down to decide what CSRF should look like in DaloyJS, my entire request was: please make it unsurprising.
This post is the result. We ship two strategies — the classic double-submit cookie and the modern Fetch-Metadata check — and a third option, strategy: "both", that runs both of them. I want to walk through why, when each one fails, and why "both" is the boring grown-up default for most production apps in 2026.
A two-minute history of CSRF defenses
CSRF exists because the browser cheerfully attaches your cookies to any cross-origin request, including ones the attacker tricks your tab into making. The defense lineage roughly goes:
- Synchronizer tokens (2005-ish) — server stamps a token into a hidden form field, server keeps it in session, compares on submit. Works, but requires server-side state and dies the moment you have a stateless API.
- Double-submit cookie(2010s) — server sets a random token in a cookie, frontend echoes it back as a header (or hidden field). The browser's same-origin policy prevents an attacker page from reading the cookie, so the echo proves the request came from a page that could read it. Stateless, framework-friendly. This is what the JS world ran on for a decade.
- SameSite cookies (2017-2020) — browsers started defaulting cookies to
SameSite=Lax, which actually eliminates the most naive CSRF without any application code. Great, but partial:Laxstill allows top-levelGETnavigations, and apps that need cross-site cookies (third-party widgets, SSO) have to opt out. - Fetch Metadata Request Headers (2020+) — the browser itself starts telling the server where this request came from, via
Sec-Fetch-Site,Sec-Fetch-Mode,Sec-Fetch-Dest. With one rule — "reject mutating requests whoseSec-Fetch-Siteisn'tsame-originornone" — you can ditch the token entirely on modern browsers.
All four defenses still exist in the wild. They are not mutually exclusive. They protect against slightly different threat models. That's why we ship two of them and let you run them together.
Strategy 1: double-submit, the way we've always done it
Three lines. The middleware mints a 32-byte URL-safe token, sets it as __Host-daloy.csrf, and on any mutating method it requires the request to echo the same value in x-csrf-token. The comparison is timing-safe.
double-submit cookie
strategy: "double-submit"- Browsers from before Sec-Fetch-Site shipped
- Server-rendered forms (token in a hidden input)
- iframes you don't control, as long as JS can read the cookie
- Apps where every fetch already goes through one helper
- Frontends that forget to set the header (this is the #1 bug)
- JS-less workflows — no cookie reader, no echo
- XSS — if an attacker can read your cookies, this falls
- Cookieless API clients (mobile apps, server-to-server)
The single most common bug with double-submit is forgetting to send the header from the frontend. That bug isn't actually a CSRF vulnerability — it just looks like one to users, who cheerfully report "the save button is broken" on a Friday afternoon. The fix is to centralize: one csrfFetch() helper, every mutation goes through it.
Strategy 2: fetch-metadata, the way browsers want to help
Here is the part that I genuinely think is underrated. Every modern browser, on every request, sends a Sec-Fetch-Site header that tells you, definitively, whether the request is same-origin or cross-site. The browsertells you. The attacker page cannot forge it; it's on the list of forbidden response headers, the user's browser puts it there, end of story.
fetch-metadata
strategy: "fetch-metadata"- Any modern browser (Chrome 76+, Firefox 90+, Safari 16.4+)
- Native fetch from SPAs / mobile webviews / Workers
- Server-to-server clients you own (you set the allowlist)
- JS-less server-rendered forms — yes, really; same-origin POST still says so
- Cross-origin SSO redirects that go through your endpoint mid-flow
- Truly legacy browsers (you fall back to Origin / Referer)
- Server-to-server calls from clients you don't control (no Sec-Fetch-Site)
There is a quirk in the spec that surprises everyone the first time, including me:
We allow same-origin and none, fall back to allowedOrigins for everything else, and on legacy browsers (no Sec-Fetch-Site at all) we check Origin and then Refereragainst the same allowlist. That last step is the one that keeps your support engineer from getting paged about "my Android 9 device can't check out".
The allowedOrigins story
allowedOrigins is the only configuration that matters once you pick fetch-metadata. It accepts a string array or a predicate, and it is used in three different places:
- When
Sec-Fetch-Siteissame-siteorcross-site— usually because of a subdomain or a user opening your site via a partner — we check the request'sOriginagainst the allowlist. - When
Sec-Fetch-Siteis missing entirely (legacy browser, some embedded webviews) — we checkOriginfirst, and if that's also missing we fall back to the origin of theRefererURL. - Predicates are how you handle wildcards like preview deployments, where you can't enumerate origins ahead of time:
One rule for predicates: keep them small and readable. The instant your predicate looks like a regex engine, you have introduced a different CSRF vector — the one where a future engineer misreads it.
Strategy 3: both, the realistic production default
Most apps I've shipped in the last three years have ended up here, and not because we couldn't pick a side. The reason is simple — the two strategies are cheap to run together, and they fail in different ways:
Think of it as a 2-of-2: a CSRF attempt would need to (a) defeat the browser's Sec-Fetch-Site reporting and (b) read the __Host-cookie from your origin to mirror it back. The first is essentially "break the browser"; the second is "break the same-origin policy or already own your DOM". Either of those means you have considerably larger problems than CSRF.
both
strategy: "both"- Production apps with mixed-modernity clients
- Multi-tenant subdomains with shared cookies
- Apps that already have a csrfFetch helper, no cost to add
- Pure server-to-server APIs with no browser involvement (use bearer auth instead)
- Tiny demos where double-clicking 'send' is the entire frontend
When each one fails, in one screen
This is the cheat sheet I keep in a comment at the top of the middleware setup, because I forget the exact rules every six months and I do not enjoy re-reading specs:
Construction-time validation: find out at boot, not at 3am
One of my favorite quiet features of the CSRF middleware is that most of the validation runs when you call csrf(), not when a request arrives. A typo in the strategy string, a cookie name with a space, a __Host- cookie without secure: true, a SameSite=None without Secure — every one of these throws at app boot, with a message that tells you exactly what to fix:
This is one of those things you only appreciate after you've shipped a CSRF config bug to production and had it manifest as "every fifth user gets a 403 but only on Tuesdays". Failing at boot is the only acceptable failure mode for security middleware configuration. If it boots, it's configured.
Picking a strategy: the actually-short version
If you only take one line away from this post: strategy: "both"is the safest default that doesn't cost anything extra, and the __Host- cookie prefix does half the security work for free. Set both, sleep better.
The honest part
CSRF, as a class, is mostly a solved problem in 2026 — between SameSite=Lax defaults, Fetch-Metadata reporting, and double-submit being two lines away, the surviving bugs are almost always configuration bugs (a cookie set without Secure, an allowedOrigins that quietly matches every preview deploy ever, a frontend that forgot to call the helper). What we tried to do with this middleware is make those configuration bugs throw at boot instead of leaking through quietly. The strategies themselves are well-trodden ground. The fail fastpart is what I'm proudest of.
If you want the full surface area, the CSRF docs have every option and an end-to-end example with a session. The security overview walks through how this fits with secureHeaders() and sessions.
Thanks for reading. Now go look at your frontend's fetchhelper and make sure every mutation actually goes through it. Don't ask me why I know to suggest that.
— Devlin