What Is CSRF?
The attack
In the previous section, we hardened the login endpoint: rate limiting, account lockout, and timing attack prevention all protect against attackers who try to guess passwords. Now we defend against a completely different kind of attack — one where the attacker does not need to guess anything because the user is already logged in.
You are logged in to your banking app. You visit a malicious website in another tab. That website has a hidden form:
<form action="https://yourbank.com/transfer" method="POST" style="display:none">
<input name="to" value="attacker-account" />
<input name="amount" value="10000" />
</form>
<script>
document.forms[0].submit();
</script> When the page loads, the form submits automatically. Your browser sends a POST request to yourbank.com/transfer with the attacker’s data. Crucially, your browser attaches your session cookie because the request goes to yourbank.com, where you are logged in.
The bank’s server sees a valid session cookie and processes the transfer. You never knew it happened.
This is cross-site request forgery (CSRF). The attacker forges a request that appears to come from you by exploiting the browser’s automatic cookie attachment.
Why cookies enable CSRF
Cookies are sent on every request to the matching origin, regardless of where the request originated. If you are logged in to example.com and a page on evil.com makes a request to example.com, your cookies for example.com are attached.
This is by design: cookies provide seamless authentication across tabs, bookmarks, and links. But it also means any website can trigger authenticated requests to any other website you are logged in to.
How SameSite=Lax helps
The auth course set SameSite=Lax on the session cookie. This tells the browser:
- Send the cookie for same-site requests (normal navigation within your app)
- Send the cookie for top-level navigations from other sites (clicking a link to your app from an email)
- Do NOT send the cookie for cross-site form submissions, fetch requests, or iframe loads from other sites
The hidden form attack above is a cross-site POST. With SameSite=Lax, the browser does not attach the cookie, and the attack fails.
When SameSite=Lax is not enough
SameSite=Lax has gaps:
GET requests with side effects. SameSite=Lax allows cookies on top-level GET navigations from other sites. If any of your GET routes change state (delete a resource, toggle a setting), an attacker can exploit this with a link: <a href="https://yourapp.com/delete-account">click here</a>.
This is why we made logout a POST in the auth course. GET requests should never have side effects. If all your state-changing routes use POST/PUT/PATCH/DELETE, SameSite=Lax covers you for those.
Older browsers. Some older browsers do not support SameSite. They treat all cookies as SameSite=None (sent everywhere). The percentage of these browsers is shrinking but not zero.
Subdomains. SameSite considers a.example.com and b.example.com as the same site. If an attacker controls a subdomain of your domain (through a compromised service, a user-controlled subdomain, or a shared hosting platform), they can make same-site requests that include the cookie.
For most applications, SameSite=Lax with proper HTTP method discipline (no side effects on GET) is sufficient. The next two lessons add explicit CSRF protection for cases where it is not.
The defense
CSRF protection works by including a secret in the request that an attacker cannot know. The server checks for this secret. If it is missing or wrong, the request is rejected.
The attacker can forge the URL, the body, and the headers of a request. But they cannot read your app’s pages (same-origin policy prevents that), so they cannot extract a secret token embedded in the page or a custom header your JavaScript sets.
We will implement two approaches:
- CSRF tokens (next lesson): A random token embedded in forms/requests and verified by the server.
- Custom header verification (lesson after): Requiring a custom header that only your JavaScript can set.
Why can the attacker's hidden form trigger an authenticated request to your app?
Why does SameSite=Lax still allow cookies on top-level GET navigations from other sites?