Skip to main content

Inside the Cookie Jar, How Chrome Actually Handles Cookies (and Why It Matters for Developers)

· 12 min read
Pere Pages
Software Engineer

Cookies are tiny key–value blobs with rules. Chrome enforces those rules rigorously: where the cookie applies (domain/path), when it’s sent (request matching + SameSite), how it’s protected (Secure/HttpOnly), and how long it survives (expiry/eviction). This post walks through the full lifecycle, browser behavior, sharp edges, and production patterns—with TypeScript examples.

  • Use server-set, Secure; HttpOnly; SameSite=Lax cookies for auth.
  • Scope deliberately with domain and path; avoid accidental overlaps.
  • Understand SameSite (and partitioning) for cross-site flows.
  • Keep cookies small, few, and purposeful.
note

This is an educational overview of how Chrome treats cookies, not compliance or legal advice — consent banners and privacy regulation are a separate topic. Browser behavior keeps evolving; verify security-critical settings against current documentation.


The lifecycle at a glance

  1. Creation

    • Server → Browser via Set-Cookie (preferred for auth/security).
    • JavaScript (JS) → Browser via document.cookie = "name=value; ..." (user-interface (UI) prefs).
  2. Storage

    • Chrome persists cookies per profile; session cookies live in memory.
  3. Attachment

    • On each request, Chrome evaluates domain, path, Secure, expiry, and SameSite to decide which cookies to send.
  4. Access

    • Server sees them in the Cookie: request header.
    • JS can only see non-HttpOnly cookies scoped to the current document’s origin/path.
    • Extensions (with permission) can read/manage via chrome.cookies.*.

Scope: domain and path (the “where”)

Domain

  • example.com (hostOnly): sent only to that exact host.
  • .example.com: sent to example.com and all subdomains (api.example.com, app.example.com).
  • Choose .example.com to share sessions across subdomains; choose hostOnly to isolate surfaces (e.g., keep admin.example.com separate from www.example.com).

Path

  • Prefix match. path=/account matches /account, /account/, /account/settings but not /.
  • Defaults to the current directory if omitted when set from JS (/shop/cart → default path /shop/).
  • Use path scoping to segment areas like /admin or /v2.

Coexistence & precedence

  • You can have multiple cookies with the same name if they differ by path and/or domain. Browsers send the ones that match; the cookie spec (Request for Comments (RFC) 6265) prefers longest path first.
  • Overwrites only happen when name + domain + path are the same.

Security model: Secure, HttpOnly, SameSite

Secure

  • Sent only over Hypertext Transfer Protocol Secure (HTTPS). Required for any sensitive data. Also required when SameSite=None.

HttpOnly

  • Hidden from document.cookie. Crucial for mitigating cross-site scripting (XSS) on session cookies.

SameSite (schemeful, i.e., scheme is considered in site calculation) decides whether a cookie rides along on cross-site requests — the main built-in defense against cross-site request forgery (CSRF), where another site tricks the browser into firing authenticated requests:

ValueSent cross-siteCSRF protectionNotes
StrictNeverStrongCan break legitimate flows (e.g., arriving from an email link)
LaxOnly top-level navigations with safe methodsGoodThe modern default if unspecified; not sent in iframes or subresource requests
NoneAlwaysNoneMust be Secure; add your own CSRF defenses

Modern baseline for sessions

Set-Cookie: session=...; Path=/; Secure; HttpOnly; SameSite=Lax
Use SameSite=None only when you must support third-party contexts (embedded widgets, federated login on a different site), and accept the extra CSRF surface with compensations (double-submit tokens, SameSite-sensitive flows).

Expiration, session, and eviction

  • Session cookie: no Expires/Max-Age → removed when the browser closes.
  • Persistent: specify Expires=<date> or Max-Age=<seconds>.
  • Eviction & limits: browsers cap total cookies, cookies per eTLD+1 (effective top-level domain plus one — e.g. example.com), and per-cookie size (~4 KB). If you push limits, Chrome will evict older/least recently used cookies. Keep cookies small and few.

How Chrome decides which cookies to send

For each outgoing request:

  1. Filter by domain (hostOnly vs parent-match) and path (prefix).
  2. Enforce Secure → HTTPS only.
  3. Check not expired.
  4. Apply SameSite based on first/third-party context.
  5. Potentially apply partitioning (more below).
  6. Order cookies (e.g., by longest path) and serialize Cookie: name=value; ....

Modern Chrome features to know

PrefixMust be SecureDomain attributeUse for
__Host-YesNot allowed (hostOnly) + Path=/Strong, host-bound session cookies
__Secure-YesAllowedSecure cookies that still need subdomain scope
These are enforced by browsers—cheap guardrails that catch config mistakes.

Partitioned cookies (CHIPS)

  • Cookies Having Independent Partitioned State (CHIPS): a cookie can be partitioned so that if it’s set in a third-party context, it’s siloed per top-level site. This mitigates cross-site tracking while still enabling embedded functionality.
  • Server attribute: Partitioned (and usually Secure; SameSite=None in third-party use).
  • Chrome increasingly restricts third-party cookies. Expect to rely on SameSite, partitioning, and/or backend patterns (token relay/redirects) to make embedded flows work.

JS vs Server: when to set cookies where

Server (Set-Cookie)JS (document.cookie)
Can mark HttpOnlyYesNo
Safe for tokens/sessionsYesNo
Typical useSessions, refresh tokens, CSRF tokenstheme=dark, lang=ca, “dismissed the banner”, A/B bucket

Server-set (preferred for auth)

  • You can mark HttpOnly, Secure, SameSite, control domain/path precisely, and avoid exposing tokens to JS.
  • Use for sessions, refresh tokens, CSRF tokens, and anything sensitive.

JS-set (UI concerns)

  • document.cookie can’t set HttpOnly. It’s fine for non-sensitive data; keep such cookies short-lived and scoped.

Practical examples

Set-Cookie: session=eyJhbGci...; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=86400

Cross-site embedded widget needs session

Set-Cookie: widget_sess=...; Path=/; Secure; HttpOnly; SameSite=None; Partitioned; Max-Age=1800

(Expect to harden CSRF with additional measures.)

Host-bound session with guardrails

Set-Cookie: __Host-session=...; Path=/; Secure; HttpOnly; SameSite=Lax

No Domain → cannot bleed to subdomains.

JS preferences (not sensitive)

document.cookie = [
"theme=dark",
"Path=/",
"Max-Age=31536000",
"SameSite=Lax"
].join("; ");

Multiple cookies with the same name (real-world behavior)

You might see:

Set-Cookie: token=AAA; Path=/; Domain=.example.com
Set-Cookie: token=BBB; Path=/admin; Domain=.example.com

Requests to /admin/* can carry both. Ordering favors the longer path first:

Cookie: token=BBB; token=AAA
On the server, don’t assume one value—parse carefully and define a canonical scope for critical cookies (prefer a unique name or a path that isolates the one you actually read).

Testing & debugging

  • DevTools → Application → Storage → Cookies: inspect, edit, delete.

  • Network tab: check Set-Cookie responses and Cookie requests.

  • curl:

    curl -I https://app.example.com \
    -H 'Cookie: theme=dark; exp-group=B'
  • Man-in-the-middle (MitM) / proxy tools can help verify attachment and SameSite behavior during cross-site navigations or iframe loads.


Common pitfalls (and fixes)

  • Missing Secure on production → cookies ride over HTTP on misconfig or local proxies. Fix: always set Secure in prod; use HTTPS everywhere.
  • Forgot HttpOnly on sessions → XSS can steal tokens. Fix: mark sessions HttpOnly and keep auth out of JS.
  • SameSite confusion → cross-site flows fail silently. Fix: understand when you truly need None (and then add Secure), or prefer Lax/Strict.
  • Overwriting didn’t work → domain/path mismatch created a parallel cookie. Fix: delete with the same triplet (name+domain+path) and re-set consistently.
  • Cookies too big → eviction or truncated headers. Fix: keep under ~4 KB each; store bulky stuff server-side.
  • Subdomain leakage → sensitive cookie available on *.example.com. Fix: use __Host- cookies or hostOnly domain.

TypeScript helpers you can drop in

// Parse document.cookie into a map (non-HttpOnly only)
export function readCookie(name: string): string | undefined {
const target = name + "=";
const parts = document.cookie.split("; ");
for (const p of parts) {
if (p.startsWith(target)) return decodeURIComponent(p.slice(target.length));
}
return undefined;
}

export function setClientCookie(
name: string,
value: string,
opts: {
path?: string;
maxAge?: number; // seconds
samesite?: "Lax" | "Strict" | "None";
secure?: boolean;
domain?: string; // avoid for host-bound prefs
} = {}
) {
const segs = [
`${name}=${encodeURIComponent(value)}`,
`Path=${opts.path ?? "/"}`
];
if (opts.maxAge !== undefined) segs.push(`Max-Age=${opts.maxAge}`);
if (opts.domain) segs.push(`Domain=${opts.domain}`);
if (opts.secure) segs.push(`Secure`);
if (opts.samesite) segs.push(`SameSite=${opts.samesite}`);
document.cookie = segs.join("; ");
}

export function deleteCookie(
name: string,
{ path = "/", domain }: { path?: string; domain?: string } = {}
) {
const segs = [
`${name}=`,
`Path=${path}`,
`Expires=Thu, 01 Jan 1970 00:00:00 GMT`
];
if (domain) segs.push(`Domain=${domain}`);
document.cookie = segs.join("; ");
}

Chrome extension — needs the "cookies" permission plus a host permission for the sites whose cookies it touches (how extension permissions work in general is covered in How Chrome Extensions Work, for Dummies):

chrome.cookies.getAll({ domain: ".example.com" }, cookies => {
cookies.forEach(c => console.log(c.name, c.value, c.path, c.sameSite));
});

chrome.cookies.set({
url: "https://app.example.com/",
name: "feature-flag",
value: "checkout-v2",
secure: true,
sameSite: "lax",
path: "/",
domain: "app.example.com"
});

Design patterns that age well

  • Keep tokens server-side; cookies carry only opaque IDs.
  • One cookie, one purpose. Avoid multiplexing JavaScript Object Notation (JSON) blobs.
  • Use __Host- for primary session; use separate names for per-section signals.
  • Prefer SameSite=Lax unless you truly need cross-site; for cross-site, combine SameSite=None; Secure with CSRF defenses.
  • Partition third-party cookies when embedding.
  • Instrument and log cookie attributes server-side (especially during migrations or library changes).

Auth flows (SPA + API)

Goals: secure, reliable, CSRF-resistant sessions for a single-page application (SPA) talking to an application programming interface (API).

Server cookie config

Set-Cookie: __Host-session=<opaque_id>;
Path=/;
Secure;
HttpOnly;
SameSite=Lax;
Max-Age=86400

__Host- prevents subdomain leakage. ✅ Secure; HttpOnly; SameSite=Lax is modern safe default. ✅ Store opaque session identifier (ID), not JSON Web Tokens (JWTs) or user data. ✅ Rotate cookies on login/logout. ✅ Clear on logout using same name+path+domain triple.

Frontend checklist

  • Never read/write session cookies via JS.
  • Use fetch with credentials: "include" or axios { withCredentials: true }.
  • CSRF defense: rely on SameSite=Lax + server-generated anti-CSRF tokens for state-changing requests.

Embedded widgets / third-party contexts

Goals: allow cookie-based sessions inside iframes or external embeds safely.

Server cookie config

Set-Cookie: widget_session=<opaque_id>;
Path=/;
Secure;
HttpOnly;
SameSite=None;
Partitioned;
Max-Age=1800

SameSite=None → allows cross-site requests. ✅ Partitioned → isolates per top-level site (CHIPS). ✅ Still requires HTTPS (Secure). ✅ Keep lifetime short (~30 min).

Frontend checklist

  • Only use this when embedding under another domain.
  • Consider token relay or postMessage API for long-term sessions.
  • Log SameSite and Partitioned behavior during quality assurance (QA) in Chrome DevTools → Network → Cookies.

Local development

Goals: mimic prod behavior safely.

✅ Use localhost + HTTPS via mkcert or self-signed cert. ✅ In .env.local:

COOKIE_DOMAIN=localhost
COOKIE_SECURE=false
COOKIE_SAMESITE=Lax

✅ Avoid .local or 127.0.0.1 subdomain experiments—Chrome treats them differently. ✅ For debugging, inspect in DevTools → Application → Cookies → http://localhost.


End-to-end tests (Cypress, Playwright, etc.)

Goals: simulate sessions easily without server logins.

✅ Use API route to log in programmatically and retrieve cookie:

const cookies = await api.post("/login", { user, pass });
page.context().addCookies(cookies);

✅ Or mock cookie in test runner:

cy.setCookie('__Host-session', 'test-session', {
domain: 'localhost',
path: '/',
secure: false,
sameSite: 'Lax',
});

✅ Always test both authenticated and unauthenticated flows. ✅ Clean cookies between tests with cy.clearCookies() or Playwright context.clearCookies().


🧪 Quick Recipes

  1. Open Application → Storage → Cookies → [site].
  2. Verify flags: Secure, HttpOnly, SameSite.
  3. Check the Expires/Max-Age column for persistence.

🧰 curl examples

Inspect what cookies server sets

curl -I https://app.example.com/login
# Look for "Set-Cookie" headers

Send a manual request with a cookie

curl -v https://api.example.com/user \
-H "Cookie: __Host-session=abc123"

Delete a cookie

curl -X POST https://app.example.com/logout
# or explicitly set expired cookie header

🧭 Local debug tip

When testing SameSite/Partitioned behavior:

chrome://flags/#cookies-without-same-site-must-be-secure
chrome://flags/#third-party-storage-partitioning

→ enable both for production-like cookie enforcement.


References

Going deeper

Related reads: