What is cross-site request forgery?
Cross-site request forgery, usually shortened to CSRF, tricks a signed-in visitor's own browser into sending a request to your site that the visitor never meant to send. The attacker never sees the visitor's password or cookie. They only need the browser to make one request while the login is active.
It works because of how cookies were designed. When a browser sends a request to riversidebirds.example, it has traditionally attached that site's cookies, whichever page started the request. A form on evil.example that posts to the club's email route would arrive carrying the member's session cookie, and the club's server would see a normal request from a signed-in member.
The damage depends on what your routes do. On the bird club, a forged request could change a member's email address, which the attacker can then use to reset the password and take over the account. On a shop it could place an order or change a delivery address.
What did the forged form actually do?
The pretend attack in the lab is a page on evil.example containing a form with hidden fields and a short script that submits it as soon as the page opens. The member sees nothing unusual, perhaps a page about garden birds. Behind it, the browser posts [email protected] to https://riversidebirds.example/account/email.
The club's route on line 6 of server.js reads req.session.memberId, finds a valid session because the cookie came along, and updates the email. Everything about the request looks legitimate. The only odd thing is where it started, and the route never asks.
Open the Network view after running the attack. You can see the request from evil.example with Origin: https://evil.example and the Cookie: session=... line attached. After your fix, the same request appears with no cookie, and the club answers with a redirect to the sign-in page.
How does SameSite stop it?
SameSite is a cookie setting that tells the browser when to include the cookie on requests started by other sites. It has three values.
Strict: the cookie is only sent when the request starts on your own site. Following a link from another site, such as a webmail message, into the club arrives signed out for that first page.Lax: like Strict, except the cookie is sent when someone follows an ordinary link or types the address. Forms posted from other sites and background requests go without it. This is the usual choice for a login cookie.None: the cookie goes with every request from every site, which is how cookies worked before SameSite existed. Browsers only accept it together withSecure.


Why did the cookie need a SameSite setting at all?
The starting cookie had no SameSite value. What happens then depends on the browser. Chrome and Edge treat a cookie with no SameSite as Lax, with one short exception: for two minutes after the cookie is set, they still send it on top-level POST requests from other sites, to avoid breaking some older sign-in flows. Firefox and Safari still send cookies with no SameSite on cross-site requests.
Because a good share of your members use Firefox or Safari, the lab treats a missing SameSite as unprotected. Writing SameSite=Lax yourself gives the same behaviour in every current browser and makes your intent clear to anyone who reads the code later.
In the browser you can check what a site actually set. Open DevTools, go to Application, then Cookies, and pick the site. The SameSite column shows the value, and an empty cell means none was set.
When do you still need a CSRF token?
SameSite is strong protection on its own, and for many small sites it is enough. It has gaps. Lax lets cookies through on link clicks, so any route that changes data on a GET request is still open, as the Break it exercise shows. SameSite also only looks at the site, meaning the registered domain. A page on blog.riversidebirds.example counts as the same site as the main club pages, so a weakness on a subdomain can still forge requests.
A CSRF token closes those gaps. The server puts a random value in each form it sends, tied to the visitor's session. When the form comes back, the server checks the value matches. A page on evil.example cannot read your forms, so it cannot know the token, and its forged request fails.
In Express in 2026, the old csurf package is deprecated and should not be used in new code. csrf-csrf is a maintained choice. Many frameworks, such as Next.js server actions, Rails and Django, check tokens or the Origin header for you. A simpler extra check is to refuse a request that changes data when its Origin header names another site, or when Sec-Fetch-Site says cross-site.
app.post('/account/email', (req, res) => {
if (req.headers.origin !== 'https://riversidebirds.example') {
return res.status(403).send('Request refused');
}
// ...update the email as before
});Why should GET requests never change data?
Browsers, link previews in chat apps, search engines and email scanners all fetch addresses with GET, often without anyone clicking anything. A link on another site carries a Lax cookie when clicked, and an image tag pointing at /walks/12/cancel inside a comment on your own site carries it on every page view. If that address cancels a booking, the booking is gone.
Keep GET for reading. Anything that books, cancels, saves, deletes or pays should be a POST, PUT, PATCH or DELETE request, sent from a form or a script. That rule makes SameSite=Lax effective and keeps crawlers from changing your data.
How does the lab simplify CSRF?
The lab models one login cookie, one attacker site and one visitor. It treats any route containing the word csrf as checking a token and any route reading req.headers.origin as checking the Origin, without testing that the check is correct. A real token check can be written wrongly, for example by accepting a missing token. Real sites also have several cookies and sometimes subdomains, which the lab does not model.
What do AI tools get wrong about CSRF?
When a cookie seems to go missing, for example inside an embedded widget or a payment pop-up, assistants often suggest SameSite=None; Secure on the login cookie. That makes the symptom go away by turning off the protection for every route on the site. Fix the widget instead, or give it a separate cookie with nothing sensitive in it.
Assistants also still recommend the deprecated csurf package, write cancel and delete links as GET routes because they are easier to link to, and sometimes claim that JSON APIs cannot be forged. Simple forms can still post to many JSON endpoints that accept other content types, so check what your routes accept before relying on that.
Field notes
- CSRF makes a signed-in visitor's browser send a request they never meant to send.
- The forged form worked because the browser attached the login cookie to it.
SameSite=Laxkeeps the cookie off cross-site POST requests.- Use a CSRF token or an Origin check when you need
SameSite=Noneor older browsers.
Easy to get wrong
With no SameSite, Firefox and Safari still send the login cookie on forms posted from other sites.
None sends the login with requests from every site, which reopens CSRF for every route.
Lax still sends cookies on links, and many tools fetch links automatically. Changes belong in POST requests.
csurf is deprecated and no longer maintained.
Questions people ask
Does HTTPS stop CSRF?
No. The forged request travels over HTTPS like any other. HTTPS protects the contents on the network; CSRF uses the visitor's own browser.
Does HttpOnly stop CSRF?
No. HttpOnly stops scripts reading the cookie. CSRF never reads it; the browser attaches it automatically.
Should I use Strict or Lax?
Lax suits most login cookies. Strict is safer, and members who follow a link from another site, webmail included, will look signed out until their next click.
Does cross-site scripting break CSRF protection?
Yes. A script running on your own pages can read the token and send same-site requests, so escaping output comes first.