guest@websec ~ % man 07-csrf
07 · Cross-Site Request Forgery
CSRF tricks your logged-in browser into sending a request you never meant to send. Your cookies go along for the ride, so the app thinks it was you.
How it works
Browsers send your cookies with every request to a site, no matter which page started it. That's what keeps you logged in, and what CSRF abuses.
The attacker can't see the response. They just make things happen: change an email, move money, add an admin.
Try it
Alice is logged in to the bank. Set up the bank's defenses, then attack from the evil page.
bank.test configuration
Alice's balance: $5,000
evil.test page
7.1 Countermeasures
Use GET and POST correctly
Rule one: GET only reads. Anything that changes data uses POST, PATCH or DELETE. Most CSRF defenses skip GET on purpose, so a GET that deletes stuff is wide open.
Require a secret token on state-changing requests
The classic fix: a secret token in every form. The evil site can make your browser send cookies, but it can't read our page, so it never knows the token.
csrf_meta_tags covers Turbo and fetch. Watch out: API-only controllers have no CSRF protection, and any skip_forgery_protection deserves a second look.class ApplicationController < ActionController::Base # on by default since Rails 5.2; being explicit makes failures loud protect_from_forgery with: :exception rescue_from ActionController::InvalidAuthenticityToken do terminate_session if respond_to?(:terminate_session, true) # generator helper cookies.delete(:remember_token) redirect_to new_session_path, alert: "Please sign in again." end end <%# app/views/layouts/application.html.erb %> <%= csrf_meta_tags %> <%# token for Turbo / JS %> <%= form_with model: @transfer do |f| %> <%# hidden authenticity_token %> // your own fetch() calls fetch("/transfers", { method: "POST", headers: { "X-CSRF-Token": document.querySelector("meta[name=csrf-token]").content }, body })
// npm install csrf-sync (synchronizer token pattern) const { csrfSynchronisedProtection, generateToken } = require("csrf-sync")({ getTokenFromRequest: req => req.body._csrf || req.get("x-csrf-token"), }); app.use(session(/* … */)); app.use(csrfSynchronisedProtection); // checks POST/PUT/PATCH/DELETE app.get("/transfer", (req, res) => res.render("transfer", { csrfToken: generateToken(req) })); <!-- template --> <meta name="csrf-token" content="<%= csrfToken %>"> <form method="post" action="/transfer"> <input type="hidden" name="_csrf" value="<%= csrfToken %>"> … // fetch from your own JS fetch("/transfer", { method: "POST", headers: { "X-CSRF-Token": document.querySelector("meta[name=csrf-token]").content }, body });
# Django: CsrfViewMiddleware is enabled by default # <form method="post">{% csrf_token %} … </form> # JS: send the csrftoken cookie value in the X-CSRFToken header # Flask: pip install flask-wtf from flask_wtf.csrf import CSRFProtect csrf = CSRFProtect(app) # checks POST/PUT/PATCH/DELETE # <meta name="csrf-token" content="{{ csrf_token() }}"> # <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> # fetch(url, {method:"POST", headers:{"X-CSRFToken": token}})
Layer on SameSite cookies and origin checks
Two cheap extra layers:
SameSite=Laxcookies aren't sent on cross-site POSTs or images. They still go with normal link clicks, so it won't save a dangerous GET.- Origin checks: reject POSTs that say they came from another site.
SameSite=Lax on cookies by default (since 6.1). Check older apps haven't turned it off.Don't let other sites load your data as a script
If a URL returns private data as JavaScript, another site can load it with a <script> tag and read it. Return JSON instead, never JSONP.
.js responses.Clear persistent cookies when a forgery is detected
When a forged request is caught, also clear any "remember me" cookie, or the user just gets logged straight back in.
rescue_from ActionController::InvalidAuthenticityToken that ends the session and deletes those cookies.