CSRF double submit cookie pattern questions
I have some questions about CSRF protection, specifically about the "double submit cookie" pattern.
What I have learned so far, is that this pattern has some weaknesses. But we can add some improvements to protect ourselves from them.
Some of them are:
- Sign cookie
- Bind cookie to user
- Use custom HTTP header to send request token
- Cookie prefixes
If I understand correctly, CSRF attacks are performed by using (e.g. ,
Am I right?
Here are my questions
Is this approach correct?
3. Is it necessary to add hidden field with the token on my form when using this CSRF protection pattern?
4. Let's say before I create a custom header I also sign the cookie with some secret. For example what Next.js do is hash the token along with some secret and the result is stored on cookie. Should every secret be randomly generated?
And if so, where it should be stored?
I will appreciate if someone can explain to me how methods (Bind cookie to user & Cookie prefixes) works. Can I use one secret for every cookie stored in an .env file on the server or this is a bad pattern?
Starting with the questions:
Scripts can send CSRF requests (via
fetch), not just forms and implicit GETs of page resources. However, there are very strong limits on what types of requests a script can send cross-origin. In particular, you can't send custom headers cross-origin at all unless the target origin (your site) sends a suitable response to a CORS pre-flight request (a special OPTIONS requests that the browser automatically sends before making any cross-origin request that an HTML form couldn't make). The response would need to specifically allow the custom header(s), along with allowing to source origin and, if relevant, user credentials (generally cookies). Thus, unless you've gone well out of your way to allow unwise things in CORS, simply requiring a custom header at all is proof against CSRF. You don't need to worry about the header value; it could even be the same for every user.
With that said, though, no. There isn't any way to get the value of a cookie into a header in a way that wouldn't be readable to a same-origin script (such as one injected via XSS). You could make the cookie HttpOnly and then either include the value somewhere in the response body or make it available via an API, but a script running in the user's browser on your origin can read such values.
Unless the attacker has a script running on your domain, or is able to intercept plain-text traffic between your server and the user, the attacker won't even be able to read non-HttpOnly cookies. Even if you make CORS tear as large a hole as possible in the Same-Origin Policy, there's just no API for scripts to read cross-origin cookies. That includes cross-origin cookies in requests originating from the script's origin.
With that said, I'm not sure why you think configuring CORS is relevant to anything an HTML form does. For "simple" requests - the kinds a form can send - CORS has no impact at all except on whether the sending origin can read the response, and forms can't do that anyhow (except in the sense that navigating the page is "reading the response", but it changes origins when doing navigating). If you didn't mean using an HTML form submission, there's no need to trick a user into filling out anything (actually, there isn't anyway, forms can be pre-filled or filled using JS and submitted automatically using JS, all with no user interaction).
The hidden form field is needed if, and only if, you're relying on an anti-CSRF secret token that is otherwise only sent in a cookie. If the token is sent in a header (or if you're just relying on the existence of the header, see #1), you're fine. If you're instead relying on the SameSite attribute on the cookie (don't do this yet, some very few people still use outdated browsers), then - much like the header - it won't be sent on cross-site requests anyhow so no need to compare the value to anything. Of course, you can send the value in both header and body (as well as cookie), and just check whichever is expected for the particular endpoint; this is sometimes done for sites that need to be usable without JS (precludes custom headers) or that are based on legacy web frameworks that relied on HTML form submissions instead of submitting requests via script.
There still isn't any way to do this without XSS being able to read the value.
Signing the token with a secret generally implies a server-wide (or even cluster-wide) secret, so you'd store it the same as any other such value (possibly in an environment variable, file outside the webroot including possibly a .env, database field, etc.). There's no point in using double-submit cookies at all if you're going to require a per-user secret to verify them; the whole point of this pattern is to avoid storing any per-session or per-user data that causes a bunch of extra DB hits / cache coherency issues. If you don't mind storing per-session state and looking it up on each state-changing request, generate a random token when the session is created, store it in the DB, and require it be present in request bodies; no need for anti-CSRF cookies at all in that case (their advantage is that no server state is needed).
In general, you don't need to worry about your CSRF protection holding up in the face of XSS. XSS pretty strictly dominates CSRF in terms of what it can do; if the attacker is able to pull off an XSS attack, CSRF protections are pretty meaningless (some, like SameSite on cookies, might present a bit of a speed bump, but even then I wouldn't count on it being sufficient). Remember, once a malicious script is injected into a page running on an origin (which is what XSS is), any requests it forges aren't actually cross-site any more.
Binding anti-CSRF tokens (in a cookie or otherwise) to a user just means that the token value depends on something known about the user and not known by an attacker. A simple example is to use, as an anti-CSRF token, the hash of the user's session token (assuming it doesn't change too frequently). Since the attacker doesn't know the session token - if they did, they could just hijack the session directly, no need for CSRF or XSS - they can neither guess the actual anti-CSRF value nor plant a forged anti-CSRF cookie on the victim. Another upside if this approach is that you don't need a separate cookie for anti-CSRF; the session cookie becomes, effectively, also the anti-CSRF cookie (hash it and see if the result matches the token in the request body).
Cookie prefixes are a way to avoid the risk of cookie-planting attacks, by specifying that cookies with certain names (those that start
__Secure-can only be set under certain conditions. This is necessary because, historically, cookies could be clobbered by other cookies from less-secure sources (e.g. HTTPS-created cookies from HTTP responses, specific-domain cookies from a subdomain or parent domain). Additionally, since cookie metadata (flags, origin, etc.) is not transmitted in browser requests, the server couldn't tell the difference between (in this case) the anti-CSRF double-submit cookie that it created, and one that an attacker created (assuming the attacker had some vector for planting cookies). However, cookie names are transmitted in browser requests, so a few name prefixes were carved out for special treatment that blocks cookie planting attacks. The idea is, you'd rename your cookies from, e.g.
__Host-anticsrf. You have to change the name of the cookie that your check for in the request, too - the browser includes the prefixes when sending it back to the server - but the idea is that if those prefixed cookies are present, you can be sure the cookies weren't set by a different (sub/parent) domain or an insecure connection. Of course, this feature is new and not yet universally adapted, so it's not entirely safe to rely on it today (though it doesn't hurt to use it anyhow).
Honestly, at the end of the day, I really can't recommend anybody implement double-submit cookies today. Much like
X-Frame-Optionsand then CSP made framebusting JS obsolete, and CORS made JSONP obsolete, double-submit cookies were a quick-and-dirty hack of a solution that has been superseded by better options.
- Use script-initiated requests and add something to make them non-simple (couldn't be sent via HTML form submission), such as:
- Any custom header
- A verb other than GET, POST, or HEAD
- A content type other than
multipart/form-data(a common choice is
- A custom value in one of the standard headers that aren't a "CORS-safelisted request header", such as setting a custom
Authorizationheader containing a bearer token (in which case you can avoid security-sensitive cookies altogether).
- If you can't / don't want to do that, require that the request body (and thus, realistically, the HTML forms) contain a hidden anti-CSRF token that is the hash digest (or possibly HMAC signature generated using a server-secret key, though that adds little real benefit) of the session token.
- If you're really paranoid about timing attacks, hash the session token once to generate the hidden anti-CSRF token, and hash it twice (and the token from the request body once) before comparing.
SameSite=Laxflags on your security-relevant cookies for any site that doesn't need to support authenticated calls across sites. Use
SameSite=Strictfor cookies on sites where the site doesn't even need to support top-level navigation from an external domain directly into an authenticated request.
- While SameSite support isn't 100% yet, it's getting close, and it doesn't hurt anything to use it.
- In fact, newer browsers are starting to treat cookies as Lax SameSite by default, now, so it really doesn't hurt anything to use that. You'd need to explicitly mark the cookies
SameSite=Noneto get them to send on cross-site requests.
- EDIT: As another contributor pointed out, "Sites" (as relevant for SameSite) are broader (and just harder to identify) than "origins". SameSite acts on sites, not origins, and might therefore allow cookies in some situations where you'd expect it not to. https://jub0bs.com/posts/2021-01-29-great-samesite-confusion/