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.
The Three Cookie Security Flags
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.
| Attribute | Cross-site form POST | Cross-site fetch() | Cross-site top-level link | Use case |
|---|---|---|---|---|
| SameSite=Strict | Cookie not sent | Cookie not sent | Cookie not sent | Maximum CSRF protection; breaks cross-site link sessions |
| SameSite=Lax | Cookie not sent | Cookie not sent | Cookie sent (safe methods only) | Good default; covers most CSRF; minor UX impact |
| SameSite=None | Cookie sent | Cookie sent | Cookie sent | Third-party contexts only; requires full CSRF token defence |
| 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 | Cookie sent on all cross-site requests. Requires Secure. Fully CSRF-vulnerable without token defences. |
|
| 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 | Cookie never sent on any cross-site request. Maximum CSRF protection. Users arrive logged-out when following cross-site links. |
|
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: __Host- and __Secure-
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
Secureattribute)
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
Domainattribute - 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.
The Hardened Cookie Header
Combining everything so far, a production session cookie for DevForum:
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
});
Cookie Scoping: Domain and Path
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.
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 value | Meaning |
|---|---|
same-origin | Request came from the same scheme + host + port (your own JavaScript) |
same-site | Request came from the same registrable domain but possibly a different subdomain |
cross-site | Request came from a completely different site |
none | Direct 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:
| Header | What it says |
|---|---|
Sec-Fetch-Mode | How the fetch was initiated: navigate, cors, no-cors, same-origin, websocket |
Sec-Fetch-Dest | The 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();
}
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 | What it stops | What it misses | Where it runs |
|---|---|---|---|
| Synchronizer token | Forged requests without the token — the primary check | Requires correct implementation on every state-changing endpoint | Server — request validation |
| SameSite=Strict | Cross-site form posts and fetches reaching the server with the cookie | Top-level GET state changes; subdomain attacks; legacy browsers | Browser — cookie transmission |
| SameSite=Lax | Cross-site form posts and fetches | Top-level GET state changes; subdomain attacks; legacy browsers | Browser — cookie transmission |
| __Host- prefix | Subdomain cookie injection — cookie bound to exact host | Does not prevent cross-site requests — only locks cookie origin | Browser — cookie acceptance |
| Fetch metadata guard | Cross-site non-navigational requests before business logic | Safari; old browsers; requests without Sec-Fetch-Site header | Server — pre-validation |
| Origin/Referer check | Cross-origin requests from identifiable origins | Privacy tools that strip headers; some browser edge cases | Server — pre-validation |
The minimum viable combination:
- Synchronizer tokens (or double-submit with
__Host-) on every state-changing endpoint SameSite=Laxon the session cookie (already the browser default since 2020 — set it explicitly regardless)__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
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.
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.
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.
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.
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.
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.