Circumvents SameSite=Lax cookie protection using same-site subdomains, top-level navigations, or within-site redirects.
TL;DR
sub.target.com XSS → requests to target.com are same-site, all cookies senttarget.com/redirect?url=/account/delete converts cross-site to same-site<iframe> sends Origin: null — permissive servers allow itSameSite=Strict + __Host- prefix + CSRF tokens + Fetch Metadata headersSameSite cookie bypass CSRF (CWE-352) describes attacks that circumvent the SameSite cookie attribute — the primary modern defense against cross-site request forgery. SameSite controls when cookies are included in cross-site requests. When applications rely on SameSite as their only CSRF defense (omitting synchronizer tokens and Fetch Metadata checks), any bypass in the SameSite enforcement renders them fully vulnerable.
There are five distinct bypass paths as of 2024–2026. Each targets a different SameSite value or a different browser mechanism. The 120-second Lax+POST window is unique to Chrome and applies only to cookies without an explicit SameSite attribute — but since only 3.52% of websites set any SameSite attribute at all, this window affects the vast majority of production applications. The sibling domain bypass works against all SameSite values simultaneously because the definition of "same-site" is broader than "same-origin."
Defense-in-depth is mandatory. SameSite=Strict + CSRF tokens + Fetch Metadata headers together reduce the viable bypass surface to near zero. Any single defense in isolation has known bypass paths.
SameSite=Lax is designed to block cross-site form submissions while allowing natural navigation. It explicitly allows cookies on top-level GET navigation — when the browser changes the URL in the address bar via a link, window.location, or meta refresh. State-changing GET endpoints are therefore fully exploitable even against SameSite=Lax-protected session cookies.
<!-- Top-level navigation — browser sends SameSite=Lax cookies -->
<script>window.location = 'https://target.com/account/email?email=attacker@evil.com';</script>
<!-- Or via meta refresh — no JavaScript required -->
<meta http-equiv="refresh" content="0;url=https://target.com/transfer?to=attacker&amount=5000">
<!-- Or via HTTP method override — GET treated as POST by the framework -->
<!-- Symfony/Rails/Laravel: ?_method=POST or X-HTTP-Method-Override: POST -->
<a href="https://target.com/account/password?_method=POST&new_password=hacked123">
Claim your prize
</a>The method override deserves special attention. Symfony's _method parameter, Rails' method_override middleware, and Laravel's _method field allow a GET request to be processed as POST, PUT, or DELETE. SameSite=Lax sends cookies with the GET transport method. The server processes the overridden POST. Lax is bypassed.
Chrome applies SameSite=Lax to cookies that omit the attribute, but grants a 120-second grace period where cross-site POST requests are allowed for freshly-issued cookies. This is a browser-level heuristic, not specified in RFC 6265bis, designed to avoid breaking SSO flows that issue session cookies and then POST cross-site.
Attack timing:
T+0s: Attacker forces top-level navigation to target.com
→ Target issues new session cookie (SameSite=Lax by default, no explicit attribute)
→ 120-second grace period begins
T+0.5s: Attacker redirects victim to CSRF payload page (popup or iframe)
→ Page submits cross-site POST to state-changing endpoint
T+1s: Browser includes the Lax cookie in the cross-site POST (within grace period)
→ Server executes the state change
→ CSRF succeeds despite SameSite=Lax "protection"PoC structure:
// Step 1: Force cookie refresh via top-level navigation to target
window.open('https://target.com/'); // triggers cookie re-issuance
// Step 2: After brief delay, submit CSRF form
setTimeout(() => {
document.getElementById('csrf-form').submit();
}, 500); // Well within the 120-second window
// The critical constraint:
// - Only works for cookies WITHOUT an explicit SameSite attribute
// - Explicit SameSite=Lax is NOT subject to the grace period
// - This distinction is why explicit SameSite=Lax is stronger than the defaultThe 2-minute window distinguishes SameSite=Lax (explicit, set by developer) from SameSite=Lax (implicit, browser default for cookies without any attribute). Only the implicit default carries the grace period. Setting SameSite=Lax explicitly eliminates this window. Setting SameSite=Strict eliminates both the window and all GET navigation vectors.
SameSite=Strict blocks all cross-site requests, including top-level navigation. However, if the target domain has a server-side open redirect or a client-side JS redirect using attacker-controlled input, requests can be escalated from cross-site to same-site:
Step 1: Attacker sends victim to: https://target.com/redirect?url=/account/email?email=attacker@evil.com
Step 2: target.com's /redirect endpoint performs: 302 Location: /account/email?email=attacker@evil.com
Step 3: Browser follows the 302 — this is now a same-site request (origin: target.com)
Step 4: Browser sends SameSite=Strict cookies (same-site request)
Step 5: /account/email processes the state changeThe key mechanism: after the first redirect, the browser considers all subsequent requests as originating from target.com (same-site), even though the chain started from evil.com. Server-side 3xx redirects change the perceived origin of the chain.
Client-side redirects using window.location = document.URL.searchParams.get('url') produce the same result. These "gadgets" exist in virtually every large web application — search for redirect_uri, return_url, next, continue, destination, url parameters in the target's codebase.
The browser's definition of "same-site" is eTLD+1 (effective top-level domain + 1 label), not origin. All subdomains of target.com are same-site with target.com. A script running at assets.target.com, api.target.com, or cdn.target.com can issue requests to app.target.com that the browser classifies as same-site — bypassing all SameSite restrictions.
// Script executing on assets.target.com (via XSS, subdomain takeover, or CORS misconfiguration)
// This request is SAME-SITE with app.target.com
fetch('https://app.target.com/account/admin', {
method: 'POST',
credentials: 'include', // includes SameSite=Strict cookies
body: JSON.stringify({ role: 'admin' }),
headers: { 'Content-Type': 'application/json' },
});
// All SameSite cookies for target.com are included — Strict, Lax, and NoneThis bypass chain: (1) find XSS on any subdomain of the target, (2) use the XSS to issue same-site requests to the main application. The __Host- cookie prefix mitigates against subdomain cookie injection but not against cross-subdomain same-site requests — the request authentication still relies on cookies that are valid across the entire eTLD+1.
Browsers send Origin: null from sandboxed iframes (<iframe sandbox="allow-scripts">), some data: URLs, and file:// contexts. If the server's origin validation accepts null or absent origins (a common mistake), the sandboxed iframe can POST cross-site without origin attribution:
<!-- The sandboxed iframe — sends Origin: null, not Origin: evil.com -->
<iframe sandbox="allow-scripts allow-forms" srcdoc="
<form method='POST' action='https://target.com/account/email'>
<input type='hidden' name='email' value='attacker@evil.com'>
<script>document.forms[0].submit();</script>
</form>
">The server receives Origin: null. A vulnerable origin check:
# VULNERABLE origin validation
def check_origin(request: Request):
origin = request.headers.get("Origin")
if not origin or origin == "null":
return # BUG: null or absent treated as allowed
if not origin.startswith("https://target.com"):
raise Forbidden("Invalid origin")The fix: treat Origin: null as an untrusted cross-origin source and reject it for state-changing requests.
| Bypass | Targets | Technique | SameSite Defeated |
|---|---|---|---|
| GET top-level nav | Lax, None, Absent | window.location, <a>, meta refresh | Lax (GET nav allowed) |
| Method override | Lax | ?_method=POST, framework trick | Lax (GET transport) |
| 120-second window | Absent (Lax default) | Trigger cookie refresh + immediate POST | Lax implicit default |
| Open redirect chain | Strict, Lax, None | Server-side or client-side redirect gadget | All (same-site after redirect) |
| Sibling domain XSS | Strict, Lax, None | XSS on any subdomain of eTLD+1 | All (same-site definition) |
| Null Origin | Any | Sandboxed iframe | Server-side origin validation |
PortSwigger Lab — "SameSite Lax bypass via cookie refresh": Demonstrates the 120-second window exploit. The lab forces a top-level navigation to refresh the session cookie, then immediately submits a cross-site POST that succeeds because the cookie is in its grace period. Published in PortSwigger Web Security Academy as a confirmed browser behavior, not a theoretical attack.
PortSwigger Lab — "SameSite Lax bypass via method override": Symfony's _method parameter exploited to trigger POST-equivalent action via GET request. SameSite=Lax cookies are sent with the GET. The framework processes the overridden method. This applies to any Symfony, Rails, or Laravel application with method override enabled.
CVE-2024-4994 — GitLab GraphQL (CVSS 8.1): The GitLab CSRF exploit chain used content types that bypassed CORS preflight — itself a form of SameSite-adjacent bypass where the CORS mechanism was expected to serve as CSRF protection. HackerOne #1122408 documented the GET-based mutation vector that bypassed all SameSite restrictions via the GraphQL GET interface.
HackerOne #1860380 — Acronis PUT CSRF ($600): Chained attack combining client-side path traversal to reach the target path, cookie bomb to evict legitimate cookies, and PUT-based CSRF — exploiting the same-site origin definition to bypass SameSite protection on the target subdomain.
Set-Cookie headers for all session cookies. If SameSite is absent or None, the application has no SameSite protection.?_method=POST to GET requests for POST endpoints. Test X-HTTP-Method-Override: POST header. A 2xx response confirms the framework accepts method override.redirect, return_url, next, destination, continue parameters. Test whether they redirect to arbitrary paths or domains.Origin: null header in Burp Repeater. If the server returns 2xx without an error, null Origin is accepted.Automated scanners rarely cover SameSite bypass chains — they require multi-step exploitation logic. BreachVex detects the GET state-change bypass (Bypass 1) via a differential probe. The 120-second window requires timing-aware scripted testing. The sibling domain bypass requires subdomain enumeration combined with XSS scanning — a multi-step chain that BreachVex's attack-surface mapping supports.
No single control covers all five bypass paths. Implement all three layers:
Layer 1: SameSite=Strict (explicit, not default)
Layer 2: Synchronizer Token (session-bound, CSPRNG, 128-bit)
Layer 3: Fetch Metadata Resource Isolation PolicyCookie configuration:
Set-Cookie: __Host-SID=<token>; Path=/; Secure; HttpOnly; SameSite=StrictThe __Host- prefix is critical: it prevents subdomains from injecting an overriding cookie for the double-submit pattern. Without __Host-, a compromised subdomain can set SID=attacker_value; domain=.target.com.
Explicit SameSite=Strict in code:
# FastAPI — set explicit SameSite=Strict (eliminates the 120-second window)
response.set_cookie(
key="__Host-SID",
value=session_id,
httponly=True,
secure=True,
samesite="strict", # Explicit — eliminates grace period; use 'lax' only if OAuth flows require it
path="/",
)Fetch Metadata enforcement:
# Reject cross-site state-changing requests at the server level
async def fetch_metadata_guard(request: Request):
site = request.headers.get("Sec-Fetch-Site", "")
mode = request.headers.get("Sec-Fetch-Mode", "")
method = request.method
if method in ("GET", "HEAD", "OPTIONS"):
return # Safe methods always allowed
if site in ("same-origin", "same-site", "none"):
return # Same-origin and direct nav always allowed
if mode == "navigate" and site == "cross-site":
return # Top-level navigation (links) allowed — needed for OAuth callbacks
# Cross-site non-navigation requests to state-changing methods → reject
raise HTTPException(status_code=403, detail="Cross-site request rejected by isolation policy")Origin validation (fallback for older browsers):
def validate_origin(request: Request):
origin = request.headers.get("Origin") or request.headers.get("Referer", "")
if not origin:
# Absent origin — allow only if Sec-Fetch-Site is enforced above
return
if origin == "null":
# Sandboxed iframe — treat as cross-site, reject for state-changing requests
raise HTTPException(status_code=403, detail="Null origin rejected")
if not (origin.startswith("https://target.com") or origin.startswith("https://www.target.com")):
raise HTTPException(status_code=403, detail="Invalid origin")The Origin header partial-match vulnerability is a common implementation error: startsWith("https://target.com") passes for https://target.com.evil.com. Use exact equality or endsWith checking with the known origin list. Never use partial substring matching against untrusted input.
Chrome applies SameSite=Lax to cookies without an explicit SameSite attribute but grants a 120-second grace period where cross-site POST requests are allowed after a cookie is freshly set. An attacker forces a top-level navigation to the target (e.g., via OAuth redirect or logout/re-login), triggering a new cookie. Within 120 seconds, a cross-site POST to a state-changing endpoint succeeds with the new Lax cookie attached.
No. SameSite=Strict blocks all cross-site requests, including top-level navigation. However, it can be bypassed via a client-side redirect gadget on the target domain: visiting target.com/redirect?url=/account/delete makes the browser issue the second request as same-site, sending Strict cookies. Open redirects and client-side JS redirects (window.location = param) are the primary bypass gadgets.
Same-site is defined at the eTLD+1 level (registrable domain), not the origin. A subdomain XSS on assets.target.com allows requests to app.target.com that are classified as same-site by the browser. SameSite=Strict and Lax cookies are both sent on same-site requests, regardless of subdomain differences. This completely bypasses SameSite protections across the entire organization.
Browsers send Origin: null from sandboxed iframes (iframe with sandbox attribute) and some file:// or data: URL contexts. If the server has permissive logic checking 'if Origin is null or absent, allow', a sandboxed iframe on the attacker's page can POST to the target without being blocked. The browser sends null Origin instead of evil.com.
Frameworks like Symfony, Rails, and Laravel accept a _method=POST query parameter or X-HTTP-Method-Override header in GET requests. An attacker sends a GET request (which SameSite=Lax allows on top-level navigation) with ?_method=POST, and the framework processes the request as a POST, executing the state change while the browser sends Lax cookies because the transport method is GET.
Force a re-login via top-level navigation (which resets the cookie). Immediately (within 2 minutes) submit a cross-site POST form to a state-changing endpoint. If the POST succeeds with a 2xx response, the Lax+POST grace window is exploitable. The timing constraint means this requires scripted exploitation rather than manual testing in most cases.
A 2024 audit found that only 3.52% of websites implement the SameSite cookie attribute. This means 96.48% of applications still run session cookies without SameSite protection, making the full range of cross-site CSRF attacks viable without any bypass.
PortSwigger's 'SameSite Lax bypass via cookie refresh' lab shows the exploit: a page forces a top-level navigation to the target to refresh the session cookie, then immediately (< 120s) triggers a cross-site POST in a popup window. The POST succeeds because the freshly-issued cookie is in its Lax+POST grace period. This is a browser implementation detail, not a spec requirement.