Understanding CSRF
- 17 Mar, 2026

Cross-Site Request Forgery (CSRF), sometimes called the one-click hijack, doesn’t announce itself the way CORS does. While you’re building or testing a feature, nothing looks broken, because the request actually succeeds. That’s what makes it easy to never think about: a vulnerable endpoint looks identical to a normal one, right up until someone else is the one sending the request.
Why it’s invisible in normal use
Most attacks, phishing, social engineering, credential stuffing, need the victim to slip up somehow. CSRF doesn’t need any of that. The user doesn’t do anything unusual, and in the worst cases doesn’t do anything at all. Their browser does exactly what it always does, and that is the entire attack.
Why the browser cooperates
The mechanic behind it is one line: browsers attach a site’s cookies to any request going to that site, regardless of which page triggered the request. If you’re logged into bank.com, your browser holds a session cookie for bank.com. It doesn’t matter whether the request came from a link you clicked on bank.com itself or a form auto-submitting from evil.com in another tab, the cookie goes along either way, because the browser only looks at where the request is going. The origin of the request and the origin of the page that triggered it are two different things, and the cookie only cares about the first one.
So the attack looks like this: you’re logged into bank.com. You visit evil.com, which has a hidden form wired to submit to bank.com/change-email the moment the page loads. Your browser attaches your bank.com session cookie. The backend runs its usual checks: is the user logged in, is the session cookie valid, is the request formatted correctly. All of it comes back clean, because none of those checks depends on which page actually sent the request, and it processes the change. No password required, no access to your session token, nothing beyond you loading a page.
What actually fixes it
- Add a CSRF token to every sensitive
POST,PUT, orDELETE. The backend generates a random token, embeds it in the form (frameworks like Laravel or Rails do this by default), and rejects the request if the token doesn’t match. A page on another domain has no way to read your token to include it, since reading it would require the same cross-origin access CORS already blocks. For a stateless API with no server-side session, the same idea works as a double-submit cookie: the token sits in a cookie and has to come back matching in a request header, so a cross-site request carries the cookie automatically but still can’t forge the header. - Set session cookies to
SameSite=Strict, which stops the browser from sending them on requests that originate from another site at all.SameSite=Laxis the more common default and still blocks the dangerous cases, while allowing normal cross-site navigation like following a link. - GET requests should never modify data, use POST or better for anything that changes state. A GET-triggered mutation can be fired from something as simple as an
<img src="...">tag, no JavaScript, no form, nothing that looks like an attack at all. - Changing an email, password, or payment method should require the current password or a 2FA code again, even mid-session. That turns a forged request into one that fails at the one check it can’t forge its way past.
- Accept only
application/jsonon API endpoints and reject form-encoded submissions. That closes off the classic CSRF payload shape, a plain HTML<form>submitting to your endpoint. - Check the
OriginorRefererheader against your own domain on sensitive requests. Some proxies strip these headers, so it’s not reliable enough to stand alone, but it’s a cheap second check worth having alongside a token, not instead of one.
None of that requires the user to be careless. Every check the backend runs comes back clean, right up until the one nobody added: confirming the request actually came from your own frontend.