SameSite=Lax is the cookie default in every modern browser, and a lot of teams quietly deleted their CSRF tokens the year Chrome shipped it. The reasoning felt airtight: if the browser refuses to attach my session cookie to cross-site requests, an attacker's forged POST arrives unauthenticated, and CSRF is dead. It isn't. SameSite closes the most common hole, but it was designed as a defense-in-depth layer, not a token replacement — and the gap between "cross-site" and "cross-origin" is where the surviving attacks live.

What Lax actually restricts

Start with the precise behavior, because the whole argument turns on it. Per MDN, SameSite has three values:

  • Strict — the cookie is sent only on requests originating from the cookie's own site. A link from another site to your page won't carry it.
  • Lax — the cookie is withheld from cross-site subrequests (images, iframes, fetch, form POSTs) but is sent when the user performs a top-level navigation using a safe method. Clicking a link or typing the URL sends it; a cross-site
    does not.
  • None — sent on all requests, and now required to be paired with Secure.

When no attribute is set, browsers treat the cookie as Lax. Chrome made this the default in the Chrome 80 rollout, and the others followed.

The critical phrase is "top-level navigation using a safe method." Lax was deliberately built to not break the web: you follow a link from your email to your bank and you're still logged in. That carve-out is exactly the seam attackers work.

The definition that breaks the mental model: site ≠ origin

Most developers reach for "same-origin" when they reason about this, but SameSite operates on site, which is a much coarser boundary. Per MDN, a cross-site request is one where "the site (the registrable domain) and/or the scheme do not match." A site is the registrable domain plus the scheme — nothing about subdomain or port.

That means, as PortSwigger puts it, "a request can still be same-site even if it's issued cross-origin." https://app.example.com and https://intranet.example.com are two different origins but the same site. A SameSite=Strict cookie is attached to a request from one to the other without hesitation.

The practical consequence: an XSS or an open redirect on any subdomain of your registrable domain is a same-site position. From there the attacker can issue requests that carry your Strict cookies, and SameSite offers zero resistance because, as far as the browser is concerned, nothing cross-site is happening. Your CSRF posture is only as strong as the weakest app sharing your parent domain.

Four ways cookies still ride along

Beyond the sibling-domain problem, several concrete techniques defeat SameSite-only defenses.

1. GET-based state change. Lax sends cookies on top-level GET navigations by design. If any state-changing endpoint accepts GET — a common sin — a redirect is a complete CSRF:

javascript
document.location =
  'https://vulnerable-website.com/account/transfer?to=hacker&amount=1000000';

The browser navigates, attaches the Lax session cookie, and the transfer goes through. No token, no POST, no bypass trickery — just an endpoint that mutates state on GET.

2. Method override. Frameworks that honor a _method parameter (Symfony's _method, Rails' historical _method) let a GET request masquerade as a POST at the routing layer while the browser still sees a plain top-level GET:

html
<form action="https://vulnerable-website.com/account/transfer" method="GET">
  <input type="hidden" name="_method" value="POST">
  <input type="hidden" name="to" value="hacker">
</form>

3. Client-side redirect gadgets. A Strict cookie is withheld from a cross-site request — but if the attacker can route the victim through your own site first, the final request is same-site and carries everything. PortSwigger notes that "a client-side redirect that dynamically constructs the redirection target using attacker-controllable input" bypasses Strict, because the browser attributes the follow-on request to your site. Server-side redirects (a 302 you issue) do not have this property; the client-side JavaScript ones do.

4. The Lax+POST window. This is the sharpest one. To avoid breaking legacy single-sign-on flows, Chrome added a temporary exception: a cookie at most two minutes old is sent on a top-level cross-site POST. The Chromium FAQ describes it as "purely a temporary solution and will be removed in the future." One catch worth knowing: this only applies to cookies that defaulted to Lax. If you set SameSite=Lax explicitly, the exception does not apply. So a session cookie freshly issued at login, with no explicit attribute, is CSRF-able via cross-site POST for two minutes — and every refresh reopens the window.

What to actually do

SameSite is worth deploying — set it explicitly rather than relying on the default, which both dodges the Lax+POST window and documents intent. But treat it as one layer. Two others matter.

Keep your CSRF tokens. The synchronizer-token and double-submit patterns don't care about site boundaries; they care whether the request carries an unguessable value the attacker can't read. A sibling-domain XSS still has to exfiltrate the token, which is a strictly harder bar than issuing a same-site request.

Add a Fetch Metadata check. Modern browsers attach Sec-Fetch-Site to requests automatically, and it distinguishes what SameSite cannot: same-origin, same-site, cross-site, and none (a user-initiated navigation). web.dev recommends a Resource Isolation Policy roughly like this:

python
def allow(req):
    # Older browsers don't send the header — fail open, other layers cover it.
    if not req.headers.get('sec-fetch-site'):
        return True
    # Same-origin, same-site, and direct navigation are fine.
    if req.headers['sec-fetch-site'] in ('same-origin', 'same-site', 'none'):
        return True
    # Allow simple top-level GET navigations (links, address bar).
    if (req.headers.get('sec-fetch-mode') == 'navigate'
            and req.method == 'GET'
            and req.headers.get('sec-fetch-dest') not in ('object', 'embed')):
        return True
    return False  # respond 403

Note that this policy still trusts same-site, so it inherits the sibling-domain weakness — tighten it to same-origin only if you don't trust your subdomains. And because it varies behavior on those headers, include Sec-Fetch-Dest, Sec-Fetch-Mode, Sec-Fetch-Site in your Vary header so caches don't serve a cross-site response to a same-origin request. Sec-Fetch-* is supported in Chrome 76+, Edge 79+, Firefox 90+, and Safari 16.4+.

The headline holds: SameSite=Lax removed the easiest CSRF, the naive cross-site form POST. It did not remove CSRF. Anything on GET, anything on a sibling domain, and any freshly-set default-Lax cookie is still reachable. Layer tokens and Fetch Metadata on top, and stop treating a cookie attribute as the whole defense.

References