CSRF Tokens Deep Dive: PHP, Laravel, Express, and SameSite Cookies
Cross-Site Request Forgery (CSRF) remains a commonly exploited vulnerability in 2026, not because the defence is complicated, but because the landscape shifted under developers' feet. SameSite cookie changes in modern browsers, the deprecation of csurf in Node.js, and SPAs mixing with traditional session cookies all changed the right answer. This guide covers every defence pattern you need for PHP, Laravel, Express, and single-page apps.
What CSRF is
CSRF exploits the ambient authority of session cookies. The browser automatically includes cookies with every request to a given origin — including requests triggered by forms on other sites.
The attack walked through
Alice is logged into bank.example.com. Her browser holds a session cookie for that origin. Alice visits evil.com. The evil page contains:
<form action="https://bank.example.com/transfer" method="POST" id="f">
<input type="hidden" name="amount" value="100000">
<input type="hidden" name="to" value="attacker-account">
</form>
<script>document.getElementById('f').submit();</script>When Alice's browser submits the form, it sends her bank.example.com session cookie automatically. The bank sees an authenticated request to transfer money. Without CSRF protection, the transfer goes through.
Three things make CSRF work
- The attacker doesn't need to read the session cookie — they just need the browser to send it
- Browsers send cookies with cross-site POST requests by default (until SameSite changed this in 2020)
- Many servers don't distinguish "this request came from our own forms" vs "this request came from anywhere"
CSRF is OWASP A01 (Broken Access Control) in the 2021 list; it used to be a standalone category.
Defence #1 — Synchroniser Token Pattern (the classic)
The canonical defence. The server generates a random token, stores it in the user's session, embeds it as a hidden form input. On POST, the server compares the submitted token with the session token. Mismatch = reject.
Vanilla PHP
<?php
session_start();
// Generate token on form render (only if missing)
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$token = $_SESSION['csrf_token'];
?>
<form method="POST" action="/transfer.php">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($token) ?>">
<input name="amount">
<button>Transfer</button>
</form>On the POST handler:
<?php
session_start();
if (empty($_POST['csrf_token']) ||
empty($_SESSION['csrf_token']) ||
!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
http_response_code(403);
exit('Invalid CSRF token');
}
// ... process transferCritical: use hash_equals not == — timing-safe comparison. The difference is microseconds but it closes a theoretical side-channel.
Laravel — built-in
Laravel handles CSRF automatically via the VerifyCsrfToken middleware. In Blade templates:
<form method="POST" action="/transfer">
@csrf
<input name="amount">
<button>Transfer</button>
</form>@csrf renders the hidden input. The middleware automatically rejects POST/PUT/PATCH/DELETE requests missing or mismatching the token.
For AJAX requests, Laravel also accepts the token via the X-CSRF-TOKEN header:
fetch('/transfer', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Content-Type': 'application/json',
},
body: JSON.stringify({ amount: 500 })
});The <meta name="csrf-token" content="{{ csrf_token() }}"> tag is standard in Laravel layouts.
Express — modern replacement for csurf
The classic csurf npm package was deprecated in 2022 (CVE-2022-24434 and ecosystem abandonment). The modern replacement is csrf-csrf:
npm install csrf-csrfimport { doubleCsrf } from 'csrf-csrf';
const {
generateToken,
doubleCsrfProtection,
} = doubleCsrf({
getSecret: () => process.env.CSRF_SECRET,
cookieName: '__Host-psifi.x-csrf-token',
cookieOptions: { httpOnly: true, sameSite: 'strict', secure: true },
});
// Render a form — pass token to template
app.get('/transfer', (req, res) => {
const csrfToken = generateToken(req, res);
res.render('transfer', { csrfToken });
});
// Protect the POST route
app.post('/transfer', doubleCsrfProtection, (req, res) => {
// ... process transfer
});Defence #2 — Double-Submit Cookie (stateless)
For APIs and stateless servers where session storage is expensive. The server sets a cookie with a random value; the client reads the cookie and echoes the value back as a header (or in the request body). The server compares cookie ↔ header — match = request came from your own origin.
This is the pattern csrf-csrf uses by default. It works because:
- Attackers on evil.com can trigger a form POST to your site, but they cannot READ your cookies (same-origin policy)
- So they cannot set the matching header in the forged request
- The cookie-vs-header comparison succeeds only for requests originating from your site
Trade-offs: simpler to implement statelessly, but vulnerable if an attacker can set cookies on your subdomain (e.g., an XSS on blog.example.com sets a cookie that's valid for .example.com).
Defence #3 — SameSite cookie attribute
Since Chrome 80 (2020), cookies without an explicit SameSite attribute default to SameSite=Lax. This alone blocks most cross-site POST requests from carrying cookies.
The three values
- `SameSite=Strict` — cookie never sent on cross-site requests. Strongest protection, breaks some legitimate flows (e.g., logged-in user clicks a link from an email and lands logged-out).
- `SameSite=Lax` — cookie sent on top-level cross-site GET navigations, not cross-site POST. Good default.
- `SameSite=None; Secure` — cookie sent on all cross-site requests. Required if you genuinely need cross-site cookie access (e.g., embedded widgets). Must pair with
Secure(HTTPS-only).
Setting it
PHP — session_set_cookie_params at session_start time:
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'domain' => 'yourdomain.com',
'secure' => true, // HTTPS only
'httponly' => true, // not accessible from JS
'samesite' => 'Lax',
]);
session_start();Laravel — config/session.php:
'secure' => true,
'http_only' => true,
'same_site' => 'lax',Express with `express-session`:
app.use(session({
secret: process.env.SESSION_SECRET,
cookie: {
secure: true,
httpOnly: true,
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000,
},
}));Why SameSite alone is not enough
- Older browsers without SameSite-default behaviour still exist (mobile WebViews especially)
SameSite=Laxallows top-level cross-site GET — if your app does state-changing actions on GET (it shouldn't, but many do), they're still vulnerable- Subdomain takeover scenarios can defeat SameSite in some configurations
Combine SameSite with the Synchroniser Token or Double-Submit pattern for defence in depth.
Defence #4 — Custom request headers + strict CORS (API-only)
For pure REST / GraphQL APIs that only accept JSON:
- Require
Content-Type: application/json— browser's simple-request rules disallow this header cross-origin, so forged forms can't match - Require a custom header like
X-Requested-With: XMLHttpRequest— also blocked for simple cross-origin requests - Set strict CORS policy —
Access-Control-Allow-Originto your own origin only, not*
This works because simple-browser-form POSTs cannot set custom headers, and preflight CORS requests require server consent. Combined with strict origin checks, CSRF becomes impractical.
Caveat: only works if your API strictly rejects application/x-www-form-urlencoded and multipart/form-data. If your API accepts those (many do, for file uploads), you're back to needing tokens.
Which defence for which scenario
| Scenario | Primary defence | Additional layer |
|---|---|---|
| Traditional server-rendered PHP | Synchroniser token | SameSite=Lax cookie |
| Laravel monolith | Built-in VerifyCsrfToken middleware | Already includes SameSite |
| Express + SSR templates | csrf-csrf double-submit | SameSite=Lax |
| React/Vue SPA + REST API | Double-submit cookie OR custom header + strict CORS | SameSite=Strict |
| Mobile app + API | None needed (mobile apps send Bearer tokens, not cookies) | — |
| GraphQL over POST | Double-submit cookie | Strict CORS origin allow-list |
Testing your CSRF defences
Manual with curl
From a different origin (another terminal / curl), replay your form submission:
# First, grab a valid session cookie by logging in normally
curl -c cookies.txt -X POST https://yoursite.com/login \
-d "[email protected]&password=..."
# Now attempt a state-changing request WITHOUT a valid token
curl -b cookies.txt -X POST https://yoursite.com/transfer \
-d "amount=500&to=attacker"Should return 403. If it succeeds, you don't have CSRF protection.
Browser DevTools
Open the network panel, find a form submission, right-click → "Edit and resend". Delete or modify the csrf_token / _token field. Submit. Should return 403.
Automated
Burp Suite Community — Scanner tool (scan policy includes CSRF).
OWASP ZAP — "CSRF tokens" passive scan rule.
Integration tests in your codebase — assert that POSTs without valid tokens return 403.
Common pitfalls
- Not regenerating the token after login. If the pre-login token is still valid post-login, there's a window for session-fixation-style attacks. Regenerate on login.
- Not rotating on sensitive actions. For password change, payment, admin operations — generate a fresh token just for that form.
- Putting CSRF token in the URL. Query strings get logged in web server logs and leaked via
Refererheaders. Always use the body or a header.
- Using `==` for token comparison instead of
hash_equals/crypto.timingSafeEqual. Timing attacks are unlikely to succeed in practice but the fix is one character.
- Excluding too many routes from the CSRF middleware. Laravel's
VerifyCsrfToken::$exceptarray — audit it quarterly. Every exclusion is a potential CSRF hole.
- Logout endpoint unprotected. Attackers can force-logout users repeatedly, denying service. Logout should be CSRF-protected like any other state-changing action.
- State-changing actions on GET.
GET /posts/delete/42is a CSRF nightmare — any image tag anywhere can trigger it. Always use POST / DELETE for state changes.
- CSRF on file uploads forgotten.
multipart/form-datais a common form encoding — still needs a CSRF token. Check your upload routes.
- SPA + cookies without anti-CSRF. Common assumption: "I'm using an SPA with JSON API, CSRF doesn't apply to me." It does — if you use cookies for authentication. If you use Bearer tokens in Authorization headers, CSRF doesn't apply.
- Testing CSRF only against the attack origin you know. A production web app has many origins it communicates with (subdomains, CDN). Audit the full CORS policy and cookie scope.
Real-world incidents
- 2008 Netflix — CSRF allowed attackers to add movies to victims' queues.
- 2012 ING Direct (US) — CSRF-triggered fund transfers.
- 2019 Discord — SameSite was bypassed via nested iframes before Chrome tightened behaviour.
Pattern: CSRF vulnerabilities keep being re-discovered. Defence is cheap; accepting the defence as a baseline is essential.
Integration with Domain India hosting
- WordPress — core CSRF protection (
wp_create_nonce,check_admin_referer) is enabled by default. Audit third-party plugins — some disable nonce checks for user-facing forms. - cPanel-hosted PHP apps — session-based CSRF works out of the box. PHP session cookies have proper SameSite defaults in PHP 7.3+.
- Node.js on our VPS — combine
csrf-csrf+helmet(for security headers — see our Security Headers article). - ModSecurity / OWASP CRS on our cPanel plans flags obviously malicious patterns, but does not replace application-level tokens.
Frequently asked questions
Is CSRF protection needed if I use JWT in the `Authorization` header?
No. CSRF exploits automatic cookie inclusion. Bearer tokens in headers are not sent automatically — browsers don't include them cross-origin. If your API is purely Bearer-token-based, CSRF is not applicable. If you store the JWT in a cookie, you need CSRF protection.
Does Laravel's `VerifyCsrfToken` protect me completely?
For state-changing requests that go through web routes — yes. For API routes (routes/api.php), CSRF is NOT applied by default; those use token-based auth. Check your route file to confirm which middleware group covers your endpoints.
What's the difference between CSRF and XSS?
CSRF — attacker exploits your session to take actions you didn't intend. XSS — attacker runs arbitrary JavaScript in your context. XSS can read your CSRF token and defeat CSRF protection entirely. Fix XSS first (see our XSS prevention article — wait, that's SQL injection; the XSS article is coming in the same category).
Why is `csurf` deprecated on npm?
The original csurf package had architectural issues that made it hard to secure and maintain. It was unpublished in 2022. The replacement is csrf-csrf (double-submit cookie) or frameworks' built-in protection (Next.js has it; Remix has it).
Do mobile apps need CSRF tokens?
No. Mobile apps use Bearer tokens in Authorization headers, not cookies. CSRF is a cookie-ambient-authority problem.
Can SameSite cookies replace CSRF tokens?
Not completely. SameSite=Strict blocks cross-site cookies entirely but breaks legitimate flows. SameSite=Lax blocks cross-site POST but allows cross-site GET — state-changing GETs (which shouldn't exist but do) remain exposed. Combine both defences.
How often should CSRF tokens be rotated?
Per session is minimum. Per sensitive action (password change, payment) is ideal. Laravel rotates the session token on successful login — same pattern applies for custom implementations.
Need a CSRF audit of your own app? [email protected] — we can review your token-verification logic and cookie configuration as part of standard support.