The Cookie Has a Site Boundary
Cookies are ambient authority with a memory.
Once a browser stores a session cookie for https://bank.example, later
requests to that host can carry the cookie without the application explicitly
putting credentials into the request body. That is the feature. It is also the
trap behind cross-site request forgery:
<form action="https://bank.example/transfer" method="post">
<input name="amount" value="1000">
</form>
If a page on https://attacker.example can cause the browser to submit that
form, the browser may attach cookies for bank.example. The attacker does not
need to read the response to have caused work.
SameSite changes one part of that story. It tells the browser when the cookie
is eligible to be returned on a request. It does not tell the server whether the
request is semantically legitimate.
Site Is Not Origin
The HTML Standard defines a site from an origin by taking the scheme and the host’s registrable domain. Two sites are the same site only when both scheme and host component match after that reduction; ports are ignored.1
That means:
https://app.bank.example site: https://bank.example
https://bank.example site: https://bank.example
http://bank.example site: http://bank.example
https://attacker.example site: https://attacker.example
The first two are same-site but not same-origin. The first and third have the same registrable domain but are cross-site under the schemeful definition. That one word, site, is doing more work than it looks like.
The current HTTPWG cookie draft uses that same-site test to classify requests. A request is same-site when its current URL’s origin is same-site with the request client’s “site for cookies”; otherwise it is cross-site. For ordinary top-level documents, the “site for cookies” comes from the top-level origin, the security context the user sees in the address bar.2
So the browser is not asking:
is the request URL the same origin as the page?
It is asking a looser, cookie-specific question:
is the request URL the same schemeful site as the page's site for cookies?
That is why subdomain architecture matters. app.bank.example and
bank.example are different origins, but SameSite treats them as the same site.
The Three Useful Modes
In the cookie draft, the SameSite attribute parses into an enforcement value:
Strict, Lax, None, or Default when the attribute is absent.3
Strict is the clean rule:
cross-site request -> do not send the cookie
Lax adds the browser-compatibility exception most people trip over. A Lax
cookie can be sent on a cross-site request only when that request is a
top-level navigation using a safe HTTP method, such as GET.4
None opts into cross-site cookie sending, but the storage model rejects
SameSite=None cookies unless they also have the Secure attribute.5
The missing-attribute case is not exactly the same as writing Lax. User agents
may apply “Lax-allowing-unsafe” to recently created cookies whose SameSite flag
is Default, allowing them on top-level cross-site requests regardless of
method for a short compatibility window. The draft says deployment experience
has shown two minutes or less to be a reasonable limit.6
That exception exists because real login flows have historically used
cross-site top-level POST callbacks with freshly created transactional
cookies. It is also a reminder that “default Lax” is a deployment transition,
not a mathematical theorem.
Lab: Does The Cookie Ride Along?
The lab below implements a small cookie retrieval model:
site = scheme + registrable domain
same-site != same-origin
Strict withholds on cross-site requests
Lax allows cross-site top-level safe-method navigations
Default fresh can use Lax-allowing-unsafe
SameSite=None requires Secure to be stored
CSRF tokens are a server decision, not a cookie attribute
The default run is a classic cross-site form POST from
https://attacker.example to https://bank.example/transfer, using a
SameSite=Lax cookie and a server that requires a CSRF token for unsafe
actions. The executable model gives:
| Default metric | Value |
|---|---|
| Cookie stored | yes |
| Request same-site | no |
| Cookie sent | no |
| Server blocked by token check | no |
| State changed | no |
| Audit | passes |
Switch Cookie to SameSite=None; Secure and Server to cookie only. The cookie is sent and the state change succeeds. Switch Server back to CSRF token. The browser still sends the cookie, but the server blocks the unsafe action because the request lacks the token.
Switch Request to same-site POST. The page origin is
https://app.bank.example and the target is https://bank.example; this is
cross-origin but same-site, so a Lax cookie rides along. SameSite is not a
substitute for origin-level authorization.
A Lax cookie is withheld from a cross-site unsafe top-level request.
Reproducing The Artifact
The simulator exports a Node-testable API:
node - <<'NODE'
const lab = require("./assets/js/samesite-cookie-lab.js");
const EXPECTED_SITE = "https://bank.example";
const EXPECTED_DEFAULT_SUMMARY = {
stored: 1,
sameSite: 0,
sameOrigin: 0,
cookieSent: 0,
token: 0,
blocked: 0,
stateChanged: 0,
csrfRisk: 0
};
const EXPECTED_TOTALS = {
checked: 161,
caseRows: 72,
cookieGroups: 6,
namedChecks: 17,
passed: 161,
passedChecks: 161,
total: 161,
totalChecks: 161
};
const EXPECTED_BY_COOKIE = [
"strict:12:12:2:0:2:0:12:24/24",
"lax:12:12:4:0:2:0:12:24/24",
"defaultFresh:12:12:10:2:6:2:12:24/24",
"defaultOld:12:12:4:0:2:0:12:24/24",
"noneSecure:12:12:12:2:6:2:12:24/24",
"noneInsecure:12:0:0:0:0:0:12:24/24"
];
const EXPECTED_BY_REQUEST = [
"crossImage:12:2:0:0:0",
"crossLink:12:8:0:0:0",
"crossPost:12:4:2:2:2",
"idpPost:12:4:0:4:0",
"sameSitePost:12:10:0:10:0",
"schemePost:12:4:2:2:2"
];
const EXPECTED_NAMED_CHECKS = [
"site-subdomain:1/1:true",
"site-apex:1/1:true",
"origin-apex:1/1:true",
"schemeful-site:1/1:true",
"trailing-dot:1/1:true",
"public-suffix:1/1:true",
"strict-cross-link:1/1:true",
"lax-cross-link:1/1:true",
"lax-cross-post:1/1:true",
"lax-subresource:1/1:true",
"lax-same-site-post:1/1:true",
"fresh-default-post:1/1:true",
"old-default-post:1/1:true",
"none-requires-secure:1/1:true",
"none-cookie-only-risk:1/1:true",
"csrf-token-blocks-risk:1/1:true",
"fresh-default-idp-post:1/1:true"
];
function sameJson(actual, expected) {
return JSON.stringify(actual) === JSON.stringify(expected);
}
function rowDrifts(actualRows, expectedRows) {
const drifts = [];
const rows = Math.max(actualRows.length, expectedRows.length);
for (let index = 0; index < rows; index += 1) {
const actual = index < actualRows.length ? actualRows[index] : null;
const expected = index < expectedRows.length ? expectedRows[index] : null;
if (!sameJson(actual, expected)) {
drifts.push({ index, actual, expected });
}
}
return drifts;
}
const site = lab.siteFor("https://app.bank.example/dashboard");
const defaultSummary = lab.evaluate({}).summary;
console.log(site);
console.log(defaultSummary);
const audit = lab.auditSameSiteCookieLab();
const failed = audit.cases.filter(
(row) => !row.passed || row.passedChecks !== row.totalChecks
);
const failedNamedChecks = audit.namedChecks.filter(
(row) => !row.passed || row.passedChecks !== row.totalChecks
);
const totals = {
checked: audit.checked,
caseRows: audit.caseRows,
cookieGroups: audit.byCookie.length,
namedChecks: audit.namedChecks.length,
passed: audit.passed,
passedChecks: audit.passedChecks,
total: audit.total,
totalChecks: audit.totalChecks
};
const byCookieRows = audit.byCookie.map((row) => [
row.cookie,
row.cases,
row.stored,
row.cookieSent,
row.blocked,
row.stateChanged,
row.csrfRisk,
row.passed,
`${row.passedChecks}/${row.totalChecks}`
].join(":"));
const byRequestRows = Object.values(audit.cases.reduce((groups, row) => {
if (!groups[row.request]) {
groups[row.request] = {
blocked: 0,
cases: 0,
cookieSent: 0,
csrfRisk: 0,
request: row.request,
stateChanged: 0
};
}
const group = groups[row.request];
group.cases += 1;
group.cookieSent += row.cookieSent;
group.blocked += row.blocked;
group.stateChanged += row.stateChanged;
group.csrfRisk += row.csrfRisk;
return groups;
}, {})).map((row) => [
row.request,
row.cases,
row.cookieSent,
row.blocked,
row.stateChanged,
row.csrfRisk
].join(":")).sort();
const namedRows = audit.namedChecks.map((row) =>
`${row.name}:${row.passedChecks}/${row.totalChecks}:${row.passed}`
);
const byCookieDrifts = rowDrifts(byCookieRows, EXPECTED_BY_COOKIE);
const byRequestDrifts = rowDrifts(byRequestRows, EXPECTED_BY_REQUEST);
const namedDrifts = rowDrifts(namedRows, EXPECTED_NAMED_CHECKS);
const auditShape = {
site,
defaultSummary,
totals,
byCookieDrifts,
byRequestDrifts,
namedDrifts
};
const shapeErrors = [
site === EXPECTED_SITE ? null : "site",
sameJson(defaultSummary, EXPECTED_DEFAULT_SUMMARY) ? null : "defaultSummary",
sameJson(totals, EXPECTED_TOTALS) ? null : "totals",
byCookieDrifts.length === 0 ? null : "byCookieRows",
byRequestDrifts.length === 0 ? null : "byRequestRows",
namedDrifts.length === 0 ? null : "namedRows"
].filter(Boolean);
console.table(audit.byCookie.map((row) => ({
cookie: row.cookie,
cases: row.cases,
passed: row.passed,
stored: row.stored,
sent: row.cookieSent,
blocked: row.blocked,
changed: row.stateChanged,
csrfRisk: row.csrfRisk,
checks: `${row.passedChecks}/${row.totalChecks}`
})));
if (shapeErrors.length) {
throw new Error(
`SameSite audit shape drifted in ${shapeErrors.join(", ")}:\n` +
JSON.stringify(auditShape, null, 2)
);
}
const summary = `${audit.passedChecks}/${audit.totalChecks} checks passed`;
if (
failed.length ||
failedNamedChecks.length ||
!audit.ok ||
audit.passedChecks !== audit.totalChecks
) {
throw new Error(JSON.stringify({
failed,
failedNamedChecks,
passedChecks: audit.passedChecks,
totalChecks: audit.totalChecks
}, null, 2));
}
console.log(summary);
NODE
Default output:
{
"stored": 1,
"sameSite": 0,
"sameOrigin": 0,
"cookieSent": 0,
"token": 0,
"blocked": 0,
"stateChanged": 0,
"csrfRisk": 0
}
The audit currently runs 17 named site/cookie checks plus 72 generated cookie/request/server rows, for 161 total checks. Among the invariants:
https://app.bank.exampleandhttps://bank.exampleare same-site but not same-origin;http://bank.exampleandhttps://bank.exampleare cross-site;SameSite=Strictis withheld on cross-site links;SameSite=Laxis sent on cross-site top-level safe navigations but not cross-sitePOSTor subresource requests;- fresh default cookies can be sent on unsafe top-level requests in the Lax-allowing-unsafe compatibility window;
SameSite=NonewithoutSecureis rejected before retrieval;- a server-side CSRF token blocks the unsafe action even when the cookie is sent.
What I Would Put In A Review
SameSite is a useful default, but I would not approve a sensitive state-changing endpoint with “we set SameSite=Lax” as the only CSRF argument.
The review checklist is shorter and sharper:
| Question | Evidence |
|---|---|
| Is the action safe? | GET, HEAD, OPTIONS, and TRACE must not mutate application state. |
| Which cookies authorize writes? | Prefer a write-capable cookie that is stricter than a read/session-display cookie. |
| Can an attacker create a same-site request? | Check sibling subdomains, redirects, and XSS on any same-site origin. |
| Are cross-site embeds required? | Use SameSite=None; Secure only for cookies that genuinely need cross-site contexts. |
| Is there a server proof? | Unsafe actions should require a CSRF token, same-origin nonce, or equivalent server-validated proof. |
The cookie can refuse to ride along with many cross-site requests. It cannot decide whether a request that did arrive is the one the user meant to send.
-
HTTPWG, Cookies: HTTP State Management Mechanism, Section 5.2, published as the latest Internet-Draft on June 18, 2026. ↩
-
HTTPWG, Cookies draft, Section 5.6.7. ↩
-
HTTPWG, Cookies draft, Section 5.6.7.1. ↩
-
HTTPWG, Cookies draft, Section 5.6.7.2. ↩