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.

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.

storedyes
same-siteno
cookie sentno
blockedno
changedno
auditpasses

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.example and https://bank.example are same-site but not same-origin;
  • http://bank.example and https://bank.example are cross-site;
  • SameSite=Strict is withheld on cross-site links;
  • SameSite=Lax is sent on cross-site top-level safe navigations but not cross-site POST or subresource requests;
  • fresh default cookies can be sent on unsafe top-level requests in the Lax-allowing-unsafe compatibility window;
  • SameSite=None without Secure is 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.