Web
CSRF and SameSite cookies: requests sent in the user's name
How CSRF works, how much SameSite cookies protect you, CSRF tokens, APIs with cookies vs bearer tokens, and how CORS fits in. With Laravel and Node examples.
· 9 min read

Cross-Site Request Forgery (CSRF) is an attack in which a user, without knowing it, sends a request to your site with their own session. The user is logged in to your system and then opens another page. That page submits a hidden form with a POST to your site. The browser, as usual, attaches your site's cookies to the request — and the server accepts it as a genuine user action.
In recent years browsers introduced the SameSite cookie attribute, and CSRF became considerably harder. But the conclusion "CSRF is gone" is wrong. In this article we look at how CSRF works, what SameSite closes and what it doesn't, and how tokens, APIs and CORS relate to each other.
What the attack looks like
Imagine your application has an endpoint for changing the email address:
POST /profile/email
Cookie: session=...
email=new@example.uz
The attacker places this on their own page:
<form action="https://app.example.uz/profile/email" method="POST" id="f">
<input type="hidden" name="email" value="attacker@evil.example">
</form>
<script>document.getElementById('f').submit();</script>
If the user is logged in and the server relies only on the session cookie, the email changes. The attacker then takes over the account via "forgot password". The same technique works for transferring money, changing roles or deleting data.
Note that the attacker cannot read the response. CSRF is a "blind" attack; its goal is not to steal data but to change state.
Why it matters for the expertise
CSRF is classified as CWE-352 and in the OWASP Top 10 (2021) falls under A01 "Broken Access Control". OWASP ASVS explicitly requires CSRF protection for state-changing requests. The expertise typically checks:
- Whether state-changing forms carry a CSRF token and whether the server actually validates it.
- Whether session cookies have the
SameSite,SecureandHttpOnlyattributes. - Whether state can be changed through a
GETrequest. - Whether CORS settings let a foreign domain send a request with cookies and read the response.
SameSite: what it closes and what it doesn't
The SameSite attribute tells the browser whether to attach a cookie to requests initiated from another site:
- Strict — the cookie is sent only on requests within your own site. Even when following a link from another site, the user appears "not logged in".
- Lax — the cookie is sent on ordinary top-level navigation from another site (a
GETvia a link), but not onPOSTforms,fetch,iframes or images. - None — the cookie is sent everywhere.
Secureis mandatory in this case.
Most modern browsers treat a cookie without the attribute as Lax. Laravel also uses lax by default for the session cookie (same_site in config/session.php).
Lax closes classic POST CSRF. But it is not complete protection:
- State changes via GET. If
/logout,/delete?id=5or/approve?id=7work withGET,Laxwill send the cookie and the attack works. - "Site" versus "origin". SameSite works at the site level:
app.example.uzandblog.example.uzare the same site. If one of the subdomains has XSS or has been handed over to a third party, a request from there is "same-site" and SameSite does not help. - Cases that need
SameSite=None. If the application is embedded in an iframe on another site, or there is a third-party integration, the cookie is switched toNoneand the protection is gone. - Older browsers and non-standard clients do not always interpret the attribute the same way.
SameSite is a good extra layer, but it does not replace a CSRF token.
CSRF tokens: the primary defence
The classic defence is the synchronizer token: the server stores a random token in the session, adds it to every form as a hidden field and validates it on every state-changing request. A foreign site does not know the token and cannot read it.
Laravel
In Laravel, CSRF protection is on by default in the web middleware group. In Blade forms:
<form method="POST" action="/profile/email">
@csrf
<input type="email" name="email">
</form>
Axios and Inertia read the XSRF-TOKEN cookie and send it automatically in the X-XSRF-TOKEN header. The main thing to check is the exception list. In Laravel 11+, in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'webhooks/payment', // genuine external webhooks only
]);
})
Only add webhooks called by external services to the exceptions, and verify them with a signature (HMAC). Adding 'api/*' or '*' because "it wasn't working" is a common and serious mistake.
Node (Express)
The once widely used csurf package has been deprecated. Instead, use libraries that implement the double-submit cookie pattern, or write minimal protection yourself: an Origin header check plus a token.
const ALLOWED_ORIGINS = new Set(['https://app.example.uz']);
function verifyOrigin(req, res, next) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
const origin = req.get('Origin') || '';
if (!ALLOWED_ORIGINS.has(origin)) {
return res.status(403).json({ error: 'Invalid origin' });
}
next();
}
app.use(verifyOrigin);
The Origin check works well in modern browsers: on POST requests the browser sets this header itself and JavaScript cannot forge it. You can also use the Sec-Fetch-Site header: reject state-changing requests where it is cross-site.
For the session cookie:
app.use(session({
secret: process.env.SESSION_SECRET,
cookie: { httpOnly: true, secure: true, sameSite: 'lax' },
}));
APIs: cookies or bearer tokens
The root of CSRF is that the browser attaches cookies automatically. So how the API authenticates requests is decisive:
- Bearer token (
Authorization: Bearer ...) — the browser never adds this header on its own; JavaScript has to set it. A foreign site does not know the token, so CSRF is practically impossible. But if the token is stored inlocalStorage, XSS can steal it — a different risk. - Cookie-based API (SPA, Sanctum stateful) — the browser sends the cookie automatically, so CSRF protection is required. That is why Laravel Sanctum's SPA mode first calls
/sanctum/csrf-cookie, and subsequent requests are sent withX-XSRF-TOKEN.
In Sanctum, list only your own frontend domains in SANCTUM_STATEFUL_DOMAINS. For a mobile app, use token mode rather than cookies.
A frequent mistake: CSRF checks are disabled because "the API accepts JSON, so a form can't attack it". If the server does not strictly check Content-Type and parses a text/plain or application/x-www-form-urlencoded body as JSON anyway, a plain form attack becomes possible again.
How CORS fits in
CORS and CSRF are often confused. The key points:
- CORS does not protect against CSRF. CORS only controls whether a foreign page can read the response. Simple requests (form
POST,text/plain) are sent without a preflight and the server executes them — even if the response is never read. - Misconfigured CORS makes CSRF worse. If the server reflects any
Originand returnsAccess-Control-Allow-Credentials: true, a foreign site can send a request with cookies and read the response — including the CSRF token. Then the token defence falls too. - Preflight helps but is not the only defence.
Content-Type: application/jsonor a custom header triggers a preflight, and properly configured CORS will reject it. But that only works if the server strictly checksContent-Type.
In Laravel, config/cors.php:
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_origins' => ['https://app.example.uz'],
'supports_credentials' => true,
You must not combine * in allowed_origins with supports_credentials set to true — configure this properly.
How to check it yourself
Is the token really validated
Log in in the browser, use DevTools to copy a state-changing request ("Copy as cURL"), then resend it without the token:
# With token — expect 200/302
curl -si -X POST https://staging.example.uz/profile/email \
-H "Cookie: laravel_session=..." \
-d "_token=VALID&email=test@example.uz"
# Without token — expect 419 (Laravel) or 403
curl -si -X POST https://staging.example.uz/profile/email \
-H "Cookie: laravel_session=..." \
-d "email=test@example.uz"
If the second request succeeds, the protection is not working.
Origin and CORS
curl -si https://staging.example.uz/api/user \
-H "Origin: https://evil.example" \
-H "Cookie: laravel_session=..." | grep -i "access-control"
If the response contains both Access-Control-Allow-Origin: https://evil.example and Access-Control-Allow-Credentials: true, that is a serious problem.
Cookie attributes
curl -si https://staging.example.uz/login | grep -i set-cookie
The session cookie should have Secure, HttpOnly and SameSite=Lax (or Strict).
State changes via GET
Review the route list:
php artisan route:list --method=GET | grep -iE "delete|remove|logout|approve|toggle"
State-changing actions should use POST, PUT, PATCH or DELETE.
Short checklist
- All state-changing requests are protected by a CSRF token or an
Origincheck. - The CSRF exception list contains only signature-verified webhooks.
-
GETrequests do not change state. - Session cookies have
Secure,HttpOnly, andSameSite=LaxorStrict. - Cookie-based SPAs/APIs have CSRF protection enabled; Sanctum stateful domains are listed explicitly.
- The server strictly checks
Content-Type. - CORS does not reflect
Origin; credentials are allowed only for specific domains. - Subdomains are under control and checked for XSS.
Summary
SameSite cookies made CSRF considerably harder, but did not eliminate it. Reliable protection has several layers: a CSRF token or Origin check, changing state only via POST/PUT/DELETE, correct cookie attributes and strict CORS. These settings often already exist but stop working because of an exception list or a check that was "temporarily" disabled — exactly the kind of spot the expertise finds.
Want to check CSRF protection on your forms and API before the expertise? Request a security scan — the Claude AI agent reviews your routes, middleware and cookie settings, and our specialist helps close the holes we find.


