Skip to main content
Blog

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

Photo: Gugalcrom123, CC BY-SA 4.0 · Wikimedia Commons

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, Secure and HttpOnly attributes.
  • Whether state can be changed through a GET request.
  • 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 GET via a link), but not on POST forms, fetch, iframes or images.
  • None — the cookie is sent everywhere. Secure is 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:

  1. State changes via GET. If /logout, /delete?id=5 or /approve?id=7 work with GET, Lax will send the cookie and the attack works.
  2. "Site" versus "origin". SameSite works at the site level: app.example.uz and blog.example.uz are 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.
  3. 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 to None and the protection is gone.
  4. 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 in localStorage, 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 with X-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:

  1. 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.
  2. Misconfigured CORS makes CSRF worse. If the server reflects any Origin and returns Access-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.
  3. Preflight helps but is not the only defence. Content-Type: application/json or a custom header triggers a preflight, and properly configured CORS will reject it. But that only works if the server strictly checks Content-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.

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 Origin check.
  • The CSRF exception list contains only signature-verified webhooks.
  • GET requests do not change state.
  • Session cookies have Secure, HttpOnly, and SameSite=Lax or Strict.
  • 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.