About Posts Series Contact
Security 19 min read

Cookie Defences: SameSite, Prefixes, and Fetch Metadata

Synchronizer tokens stop CSRF at the server. SameSite, __Host- prefix, and Fetch metadata stop it at the browser — before the forged request ever reaches business logic. This post covers every cookie security attribute and the Fetch metadata guard that completes the defence.

Honey Sharma

The previous post ended with CSRF tokens — the server-side layer that validates every state-changing request carries a secret only the legitimate client could know.

But tokens are not the only layer. Cookies themselves can be hardened so the browser refuses to send them on cross-site requests in the first place. Request headers set by the browser — headers the attacker cannot forge — can tell your server whether a request originated from your own site or from somewhere else. Used together, these layers mean a CSRF attack never reaches the token validator, never reaches the business logic, and in many cases never leaves the browser at all.


Before covering SameSite, two other flags that every session cookie should carry:

HttpOnly

Set-Cookie: session=abc123; HttpOnly

JavaScript cannot read an HttpOnly cookie. document.cookie will not include it. A DevTools console cannot inspect its value.

This is primarily an XSS mitigation. The canonical XSS payload steals document.cookie. If the session cookie is HttpOnly, there is nothing to steal — the script can read every other cookie, but not the one that matters.

Secure

Set-Cookie: session=abc123; Secure

The browser only sends a Secure cookie over HTTPS. It is never transmitted over HTTP, even if the user navigates to an http:// URL of the same domain.

This prevents the session cookie from being intercepted on an unencrypted connection — a real risk on public WiFi, where HTTP traffic is trivially observable. In 2026, HTTPS is essentially universal, but Secure remains mandatory — it is a hard guarantee, not a convention.

SameSite: How the Browser Filters Cross-Site Requests

SameSite tells the browser which cross-site contexts should include the cookie. It has three values, each with very different behaviour.

SameSite=Strict

The browser never sends the cookie on any cross-site request — not navigations, not form submissions, not fetches.

Set-Cookie: session=abc123; SameSite=Strict; Secure; HttpOnly

If a user has a DevForum tab open and clicks a link to DevForum from another site, the browser sends them to DevForum without the session cookie. They arrive as a logged-out user and must authenticate before their session is attached. Only subsequent navigations within DevForum carry the cookie.

This is complete CSRF protection — but the UX friction is real. Sharing a link to a DevForum post via email means every recipient arrives logged out, even if they have an active session.

SameSite=Lax

The default in Chrome since 2020. Lax permits cookies on top-level navigations using safe HTTP methods (GET, HEAD) from cross-site contexts. It blocks cookies on all other cross-site requests — form POSTs, fetch calls, iframes, and non-navigational image loads.

Set-Cookie: session=abc123; SameSite=Lax; Secure; HttpOnly

A user clicks a link from Twitter to DevForum — the cookie is sent. The link takes them to a DevForum page, which looks up their session, and they arrive logged in. A forged form POST from evil.com — no cookie.

Lax eliminates most CSRF vectors while preserving the “click a link, stay logged in” behaviour that Strict breaks.

SameSite=None

The cookie is sent on all cross-site requests. Requires Secure.

Set-Cookie: session=abc123; SameSite=None; Secure; HttpOnly

This is the pre-2020 default and the setting required for third-party contexts: payment iframes, embedded widgets, cross-site SSO. A SameSite=None session cookie is fully vulnerable to CSRF — every defence from this post is necessary.

Lax is the browser default since Chrome 80 (2020). Strict provides stronger CSRF protection at the cost of cross-site link UX. None requires all other defences to compensate.
Attribute Cross-site form POST Cross-site fetch() Cross-site top-level link Use case
SameSite=StrictCookie not sentCookie not sentCookie not sentMaximum CSRF protection; breaks cross-site link sessions
SameSite=LaxCookie not sentCookie not sentCookie sent (safe methods only)Good default; covers most CSRF; minor UX impact
SameSite=NoneCookie sentCookie sentCookie sentThird-party contexts only; requires full CSRF token defence
SameSite=Lax as default for cookies without an explicit SameSite attribute
Chrome
v80+ (Feb 2020)+
Edge
v86+ (Oct 2020)+
Firefox
v69+ (explicit Lax); default since FF 79++
Safari
v13.1+ (Lax as default since macOS Big Sur)+
Full ~ Partial Flag None
Setting SameSite explicitly on every Set-Cookie header is still required — the two-minute Lax grace window in Chrome means a freshly issued session cookie briefly behaves as SameSite=None for backward-compat SSO flows. Never rely on the browser default.
SameSite Cookie States — Transitions and Consequences
State Description Transitions
No SameSite Cookie has no SameSite attribute. Chrome treats as Lax after a 2-min None window. Older browsers treat as None permanently.
  • SameSite=None (explicit None (legacy SSO / third-party embeds))
  • SameSite=Lax (explicit Lax or Chrome 80+ after grace window)
SameSite=None Cookie sent on all cross-site requests. Requires Secure. Fully CSRF-vulnerable without token defences.
  • SameSite=Lax (set SameSite=Lax; reduces CSRF surface)
SameSite=Lax Cookie sent on top-level GET navigations from cross-site; withheld on cross-site form POSTs and fetch() calls. Browser default since 2020.
  • SameSite=Strict (set SameSite=Strict; closes top-level GET vector)
SameSite=Strict Cookie never sent on any cross-site request. Maximum CSRF protection. Users arrive logged-out when following cross-site links.
  • SameSite=Lax (restore cross-site link sessions (UX trade-off))

Why SameSite=Lax Is Not Sufficient Alone

Lax handles the most common CSRF scenario. It does not handle all of them.

State-changing GET requests. Lax permits cookies on cross-site top-level GET navigations. If your application performs a state change on a GET — an email confirmation link that directly activates the account, a “unsubscribe” link that performs the unsubscription in one click — an attacker can trigger it by embedding an <img src="https://devforum.com/email/confirm?token=xxx"> or simply linking to it. Use POST for state changes. Always.

Subdomain attacks. A script running on blog.devforum.com is considered “same-site” with devforum.com. If that subdomain is compromised or user-controllable, it can make same-site requests that Lax permits — with the session cookie attached. Subdomain isolation requires more than SameSite.

The two-minute Lax window. Chrome applies a two-minute grace period: cookies without an explicit SameSite attribute are treated as None for two minutes after creation, then as Lax thereafter. This is for backward compatibility with single-sign-on flows. It means a freshly issued session cookie is briefly vulnerable to CSRF even in Chrome.

Legacy browsers. Any browser that does not support SameSite ignores the attribute entirely. Enterprise environments often have older browser versions. Mobile browsers have inconsistent implementations.

The conclusion: SameSite=Lax (or Strict) significantly reduces CSRF risk. It does not eliminate it. Layer it with synchronizer tokens and the mechanisms below.

Cookie prefixes are browser-enforced invariants — constraints the browser validates on every Set-Cookie header before accepting the cookie.

__Secure-

A cookie named __Secure-session must be:

  • Set over HTTPS (with the Secure attribute)
Set-Cookie: __Secure-session=abc123; Secure; HttpOnly; SameSite=Lax

If the server tries to set a __Secure- cookie without Secure, the browser rejects it silently. This prevents downgrade attacks where an attacker injects a cookie over HTTP to override the HTTPS cookie.

__Host-

Stricter. A cookie named __Host-session must be:

  • Set over HTTPS (with Secure)
  • Not have a Domain attribute
  • Have Path=/
Set-Cookie: __Host-session=abc123; Secure; HttpOnly; SameSite=Strict; Path=/

By omitting Domain, the cookie is bound to the exact host that set it — devforum.com. It cannot be set by blog.devforum.com and it is not sent to blog.devforum.com. The subdomain attack vector that bypasses SameSite=Lax is eliminated.

Combining everything so far, a production session cookie for DevForum:

A Hardened Session Cookie — All Security Attributes Annotated
Set-Cookie
__Host-session abc123…
HttpOnly
Secure
SameSite=Strict
Path=/
Max-Age=86400
Every attribute here is load-bearing. Removing any one of them re-opens a specific attack vector. The __Host- prefix enforces Secure and path=/ automatically — setting them explicitly is belt-and-suspenders.

Setting this in Express:

res.cookie('__Host-session', sessionToken, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict',
  path: '/',
  maxAge: 86400 * 1000,  // 24 hours in milliseconds
  // No domain: — required for __Host- prefix
});

Without any Domain attribute, a cookie is bound to the exact host that set it. Adding Domain widens the scope:

Set-Cookie: pref=dark; Domain=devforum.com   # sent to devforum.com AND all subdomains
Set-Cookie: pref=dark                        # sent only to the exact host that set it

Domain=devforum.com means the cookie is sent to api.devforum.com, blog.devforum.com, and any other subdomain. If any of those subdomains is compromised, the cookie is exposed.

Cookie Domain Scope — What Gets the Cookie
devforum.comapex domain
api.devforum.comDomain= → cookie sent
session cookie attachedto every API request
blog.devforum.comDomain= → cookie sent
user-controlled contentXSS here → session stolen
app.devforum.comDomain= → cookie sent
No Domain attr__Host- prefix enforces this
cookie bound to exact hostsubdomains never see it
With Domain=devforum.com, the session cookie is sent to every subdomain. One compromised subdomain exposes the session. Without Domain, the cookie is bound to the exact host that set it.

For session cookies, omit Domain — or use __Host-, which enforces the omission. For preference cookies that genuinely need to be shared across subdomains, Domain is appropriate, but those cookies should never carry security-sensitive values.

Path scopes the cookie to a URL prefix. Path=/admin means the cookie is only sent on requests to /admin/.... This is a convenience feature, not a security one — JavaScript on the same origin can still read cookies regardless of Path. Do not use Path as a security boundary.

Origin and Referer Header Verification

Before Fetch metadata (covered next), the standard secondary check was verifying the Origin or Referer request header.

The browser sets Origin on cross-origin requests and on same-site POST requests. An attacker on evil.com cannot forge this header — it is set by the browser, not by JavaScript.

function verifyOrigin(req, res, next) {
  const origin = req.headers.origin;
  const referer = req.headers.referer;

  // Origin is present on most CSRF-relevant requests
  if (origin) {
    const url = new URL(origin);
    if (url.hostname !== 'devforum.com') {
      return res.status(403).json({ error: 'Forbidden origin' });
    }
    return next();
  }

  // Fallback to Referer for browsers that omit Origin on same-origin POSTs
  if (referer) {
    const url = new URL(referer);
    if (url.hostname !== 'devforum.com') {
      return res.status(403).json({ error: 'Forbidden referer' });
    }
    return next();
  }

  // Neither header present — reject (conservative stance)
  return res.status(403).json({ error: 'Missing origin headers' });
}

Fetch Metadata Headers

Fetch metadata is a set of browser-generated request headers that describe the context of every outgoing request. They are set by the browser and cannot be forged by web page JavaScript.

The most important one is Sec-Fetch-Site. It tells the server where the request originated relative to the target:

Sec-Fetch-Site valueMeaning
same-originRequest came from the same scheme + host + port (your own JavaScript)
same-siteRequest came from the same registrable domain but possibly a different subdomain
cross-siteRequest came from a completely different site
noneDirect user navigation — typed URL, bookmark, opened link

A request from DevForum’s own JavaScript has Sec-Fetch-Site: same-origin. A forged form POST from evil.com has Sec-Fetch-Site: cross-site. That single header is enough to identify most CSRF attempts.

Two companion headers add more detail when you need it:

HeaderWhat it says
Sec-Fetch-ModeHow the fetch was initiated: navigate, cors, no-cors, same-origin, websocket
Sec-Fetch-DestThe destination of the request: document, script, image, empty, etc.

In practice, Sec-Fetch-Site does the heavy CSRF lifting. Sec-Fetch-Mode is used to distinguish a user clicking a link (navigate) from a programmatic cross-site fetch() call (cors or no-cors). Here is what a real attack looks like compared to a legitimate cross-site navigation:

A CSRF form post from evil.com:

Sec-Fetch-Site: cross-site
Sec-Fetch-Mode: navigate
Sec-Fetch-Dest: document

A user clicking a legitimate link from another site:

Sec-Fetch-Site: cross-site
Sec-Fetch-Mode: navigate
Sec-Fetch-Dest: document

These look identical — which is why Sec-Fetch-Site alone isn’t always sufficient for navigation-triggered state changes. The resource isolation policy below combines both headers to distinguish them.

The key insight: Sec-Fetch-Site: cross-site on a state-changing request (non-navigate mode, or navigate with a non-safe method) is a strong signal that the request did not originate from your application.

The Resource Isolation Policy

Google’s Fetch Metadata guide defines a server-side guard that uses these headers to reject illegitimate cross-site requests before they reach business logic:

function resourceIsolationPolicy(req, res, next) {
  const site = req.headers['sec-fetch-site'];
  const mode = req.headers['sec-fetch-mode'];
  const dest = req.headers['sec-fetch-dest'];

  // Allow requests from browsers that don't support Fetch metadata
  // (site header absent — fall through to CSRF token validation)
  if (!site) return next();

  // Allow same-origin requests unconditionally
  if (site === 'same-origin') return next();

  // Allow browser-initiated navigations (user clicked a link)
  if (mode === 'navigate' && req.method === 'GET') return next();

  // Block all other cross-site requests to protected endpoints
  if (site === 'cross-site') {
    return res.status(403).json({ error: 'Cross-site request blocked by resource isolation policy' });
  }

  next();
}
Fetch Metadata Resource Isolation Policy
no yes yes no yes no yes no Request Sec-Fetch-Site present? absent = old browser Fall through to token validation Sec-Fetch-Site = same-origin? Allow Mode = navigate AND method = GET? Allow — user navigation Sec-Fetch-Site = cross-site? 403 BLOCKED Allow
The policy allows same-origin requests and user-initiated navigations. Cross-site non-navigational requests are blocked before they reach any business logic. Old browsers without Sec-Fetch-Site fall through to token-based validation.
Fetch Metadata Headers (Sec-Fetch-Site, Sec-Fetch-Mode, Sec-Fetch-Dest)
Chrome
v76++
Edge
v79++
Firefox
v90++
Safari
Not implemented — Sec-Fetch-* headers are never sent
Full ~ Partial Flag None
Safari does not send Sec-Fetch-* headers. The resource isolation policy must treat absent headers as a pass-through to CSRF token validation, not as a reason to block. Do not use Fetch metadata as a sole defence.

Defence-in-Depth: All Layers Combined

Each layer stops CSRF at a different point in the request lifecycle. No single layer covers every scenario. All layers together leave an attacker with no viable path.

Layer these defences. Each one covers gaps the others leave. A complete implementation uses synchronizer tokens + SameSite=Strict + __Host- + Fetch metadata guard.
Layer What it stops What it misses Where it runs
Synchronizer tokenForged requests without the token — the primary checkRequires correct implementation on every state-changing endpointServer — request validation
SameSite=StrictCross-site form posts and fetches reaching the server with the cookieTop-level GET state changes; subdomain attacks; legacy browsersBrowser — cookie transmission
SameSite=LaxCross-site form posts and fetchesTop-level GET state changes; subdomain attacks; legacy browsersBrowser — cookie transmission
__Host- prefixSubdomain cookie injection — cookie bound to exact hostDoes not prevent cross-site requests — only locks cookie originBrowser — cookie acceptance
Fetch metadata guardCross-site non-navigational requests before business logicSafari; old browsers; requests without Sec-Fetch-Site headerServer — pre-validation
Origin/Referer checkCross-origin requests from identifiable originsPrivacy tools that strip headers; some browser edge casesServer — pre-validation

The minimum viable combination:

  1. Synchronizer tokens (or double-submit with __Host-) on every state-changing endpoint
  2. SameSite=Lax on the session cookie (already the browser default since 2020 — set it explicitly regardless)
  3. __Host- prefix on the session cookie

Add if you want the strongest posture: 4. SameSite=Strict instead of Lax (eliminates the cross-site navigation session, worth it for high-security apps) 5. Fetch metadata resource isolation policy as a pre-validation filter

Cookie Security Checklist
Set HttpOnly on every session cookie

XSS cannot steal what JavaScript cannot read. There is no legitimate reason for JavaScript to access the session cookie. If a feature requires it, redesign the feature to use a separate readable token with limited scope.

Set Secure on every cookie that carries sensitive values

HTTPS is table stakes in 2026. Secure ensures the cookie is never accidentally transmitted over HTTP — even if the user manually types http:// or a redirect momentarily lands on an insecure URL.

Use __Host- on session cookies, not just SameSite

SameSite alone does not prevent subdomain cookie injection. __Host- does. The restrictions it enforces — Secure, no Domain, Path=/ — are what a session cookie should have regardless. Adopt it as the default.

Explicit SameSite beats the browser default

Chrome defaults to Lax for cookies without an explicit SameSite, but only after a two-minute window where the cookie behaves as None. Set SameSite explicitly on every Set-Cookie. Never rely on browser defaults for security-relevant cookie behaviour.

Fetch metadata enhances but does not replace token validation

The resource isolation policy blocks most CSRF before tokens are even checked — reducing latency and server load on attack traffic. But it misses Safari and old browsers. Tokens remain the primary defence. Fetch metadata is the filter before the gate.

Never use the Path attribute as a security boundary

Path scopes when a cookie is sent — but JavaScript on the same origin can read all cookies regardless of Path. Do not design a feature where security depends on a cookie being invisible to scripts at a different path.

Honey Sharma

Software engineer focused on web engineering, TypeScript, and distributed systems.