Set-Cookie attributes and the safe default
How the Set-Cookie header's attributes control when, where, and to whom the browser re-attaches a cookie, and the safe default that secures every session in the apps you will build.
You can keep a user logged in across hundreds of requests without your app re-sending their credentials even once. The mechanism is the cookie, and the reason it works is also the reason it is dangerous: the browser attaches a cookie automatically, on every matching request, with no application code involved. That makes a cookie an ambient credential . The automatic attachment is the whole feature, since sessions stay alive without any work from you, and it is also the whole threat, since a request the user never meant to make carries their cookie too.
You already have the pieces this lesson sits on. The previous chapters built the HTTP request/response contract and the origin and CORS trust boundary, so you can read a response header and you know what “same-site” and “cross-site” mean. The cookie is the small piece of state that rides every same-site request, and it is the substrate that sessions, CSRF defenses, and server-set preferences will all be built on later in the course.
Every attribute you can put on a Set-Cookie header exists to constrain that ambient transmission: when the browser re-attaches the cookie, who can read it on the page, and where it survives. By the end of this lesson you will be able to read each attribute on a real header and say exactly what it prevents. You will also have one line committed to memory: the safe default you paste into every cookie-writing call for the rest of the course.
Set-Cookie: __Host-sid=<value>; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=2592000Do not try to parse that line yet; treat it as the destination. You will walk through it left to right, one attribute at a time, then reassemble it at the end and be able to recite it.
How the browser sends a cookie back
Section titled “How the browser sends a cookie back”Before you look at any single attribute, it helps to have the round trip clear, because every attribute is a condition the browser checks against it.
The server writes a cookie exactly once, with a Set-Cookie response header. The browser stores it. Then, on every subsequent request that matches the cookie’s conditions, the browser attaches it automatically as a Cookie request header. Your application code does not read the cookie out of storage and pin it to the request. The browser does, every time, in the background. The diagram below traces that round trip end to end.
%%{init: {'themeCSS': '.messageText, .messageText tspan { font-size: 19px !important; } .actor, .actor tspan { font-size: 18px !important; } .noteText, .noteText tspan { font-size: 16px !important; }'} }%%
sequenceDiagram
participant B as Browser
participant S as Server
rect rgba(56, 189, 248, 0.12)
Note over B,S: Set once — the server writes the cookie
S-->>B: Response (set once)<br/>Set-Cookie: sid=abc123#59; HttpOnly#59; Secure#59; ...
Note over B: Stores the cookie
end
rect rgba(34, 197, 94, 0.12)
Note over B,S: Re-sent automatically — no app code involved
B->>S: Later request (browser re-attaches)<br/>Cookie: sid=abc123
end That automatic re-attachment is the reason the attributes exist. Each one is a rule the browser checks before it decides to attach the cookie again. Tighten the rules and the browser attaches the cookie in fewer situations. That is the entire mechanism.
One header sets one cookie. The shape is always the same:
Set-Cookie: name=value; Attribute1; Attribute2=valueThe name/value pair comes first; the attributes follow, separated by semicolons. Some attributes are bare flags (HttpOnly), and some take a value (Path=/). There is one exception to the rule you learned earlier that a given HTTP header appears once in a response: a response may carry several Set-Cookie headers, one per cookie it wants to set. So Set-Cookie is the header that legitimately repeats.
Here is one full header with every part labeled, so you have a map before we read it row by row.
From here, each attribute gets the same three-beat treatment: what it controls, what breaks without it, and the default an experienced engineer reaches for without thinking.
HttpOnly: keep the cookie out of JavaScript’s reach
Section titled “HttpOnly: keep the cookie out of JavaScript’s reach”What it controls. With HttpOnly set, the cookie is invisible to JavaScript. document.cookie cannot see it; no script on the page can read it. The browser still attaches it to requests exactly as before; only the JavaScript read path is closed.
What breaks without it. The failure mode is exfiltration through XSS . If an attacker gets script running on your page and finds an unescaped sink, one line is enough to read every cookie JavaScript can see and ship it to their server. A session cookie without HttpOnly is a session that can be stolen and replayed from anywhere.
The nuance most people miss. HttpOnly does not stop XSS. It stops one specific thing: a script reading the cookie value. A script can still make authenticated requests from inside the page. A call like fetch('/transfer', { method: 'POST' }) runs in the user’s session, and the browser still attaches the cookie to that request, because the browser attaches it, not the script. So HttpOnly is defense-in-depth: it cuts off the most common and most damaging path, which is stealing the cookie and replaying it elsewhere indefinitely, without curing the underlying bug.
The default. Every session-bearing cookie is HttpOnly. The only cookies you leave readable are ones the client UI genuinely must read, such as a theme=dark preference the page applies, or a CSRF double-submit token the client echoes back.
Set-Cookie: sid=abc123; HttpOnlySet-Cookie: theme=darkThe first is a session ID the browser will carry but no script can read. The second is a preference the UI reads to paint the page, so it is left without HttpOnly on purpose.
Secure: only travel over HTTPS
Section titled “Secure: only travel over HTTPS”What it controls. A Secure cookie is attached only on HTTPS requests. Over plaintext HTTP, the browser leaves it behind.
What breaks without it. Plaintext HTTP is rare in 2026, but it is not extinct. A captive-portal redirect on café wifi, a misconfigured corporate proxy, or a stray internal link still produces an HTTP leg now and then. On any such leg, a cookie without Secure is attached in the clear, and an on-path attacker reads it straight off the wire.
Local development. Browsers treat http://localhost as a secure context for most web APIs, but Secure-cookie behavior on plain localhost is inconsistent across browsers. The local HTTPS setup you configured earlier with mkcert sidesteps this entirely: you develop over HTTPS locally, so Secure cookies behave exactly as they will in production.
The default. Every cookie is Secure. The only thing that flips it off is a legacy HTTP test fixture, which you will almost never meet on this stack.
Set-Cookie: sid=abc123; HttpOnly; SecureSameSite: the load-bearing attribute
Section titled “SameSite: the load-bearing attribute”This is the attribute that carries the most weight, so it is worth taking slowly. SameSite decides whether the browser attaches the cookie on requests that originate from another site. It is the single line that separates a session that survives a CSRF attack from one that hands the attacker a logged-in session.
It takes three values. Read them as a spectrum from strictest to loosest.
Strict attaches the cookie only on same-site requests. Even a top-level navigation coming from a third party does not carry it. If a user clicks a link to your app from their email client, that first request arrives with no cookie, so your server sees a logged-out user until they navigate again. That is the strongest CSRF defense, but it is also the worst sign-in experience: the classic symptom is a magic-link click that lands looking logged-out until the user refreshes. Reserve Strict for a few highly sensitive sub-cookies, not for your session default.
Lax attaches the cookie on same-site requests and on top-level safe-method navigations from a third party, such as an <a href> click or a GET form submission. It does not attach on cross-site POST, on <img> or <iframe> loads, or on cross-site fetch. This is the default for session cookies, and the reason is worth spelling out. The top-level GET navigation still carries the session, so the email-link UX keeps working, while the cross-site POST does not carry it, so the CSRF attack surface is gone. One historical note is worth knowing: in 2020 Chrome made Lax the implicit default when the attribute is absent, and other browsers followed. You still write it explicitly, because relying on an implicit default is how subtle behavior differences slip in across browsers and versions.
None attaches the cookie on every request, same-site or cross-site, including third-party embeds. It requires Secure: set SameSite=None without Secure and the browser rejects the cookie outright. Reserve None for a legitimate cross-site need, such as a payment iframe or an authenticated widget embedded on another site. There is also a 2026 complication: SameSite=None without the Partitioned attribute is now blocked by Safari and Firefox by default and increasingly degraded in Chrome. We will get to Partitioned shortly; for now, know that bare SameSite=None is no longer something you can rely on.
What Lax prevents. Picture the classic CSRF attack. A user is logged in to your app. They visit a malicious page in another tab, and that page silently submits a form that POSTs to your app’s transfer endpoint. Without SameSite, the browser would attach the session cookie to that cross-site POST, your server would see an authenticated request, and the transfer would go through, an action the user never authored. With SameSite=Lax, the browser does not attach the cookie to that cross-site POST, so your server sees an unauthenticated request and refuses. This is why SameSite=Lax, combined with putting every state-changing endpoint behind POST, PUT, or DELETE, retires the bulk of the CSRF problem. Token-based CSRF defenses, and exactly what Lax leaves uncovered, are a later chapter’s job; Lax plus state-changing methods is the floor, not the whole story.
The grid below is the fastest way to see why Lax is the right balance. Read each value across the three request shapes it might face.
Notice the shape of the Lax row. It is the only one that says yes to the cross-site GET navigation, so sign-in links keep working, and no to the cross-site POST, so the CSRF attack fails. Strict says no to both, which is secure but breaks the link. None says yes to everything, which is convenient but wide open. Lax is the value that threads the needle, which is why it is the default.
Set-Cookie: sid=abc123; HttpOnly; Secure; SameSite=LaxPath: scoping convenience, not a security boundary
Section titled “Path: scoping convenience, not a security boundary”What it controls. A cookie with Path=/admin is attached only on requests whose pathname starts with /admin. It is a prefix filter on the path.
The trap. If you omit Path, the browser does not default to /. It defaults to the directory of the page that set the cookie, which is usually a sub-path. Set a cookie on /admin/login with no Path, and it quietly will not attach on /admin/users. The bug looks like a session that randomly disappears as the user moves around the app, and it is hard to track down. So you set Path=/ explicitly and the cookie attaches across the whole app.
The default. Path=/.
Set-Cookie: sid=abc123; HttpOnly; Secure; SameSite=Lax; Path=/Domain: host-only by default, or you leak to every subdomain
Section titled “Domain: host-only by default, or you leak to every subdomain”What it controls. Write Domain=acme.com and the cookie is attached on acme.com and every subdomain under it.
The default is to leave it off. Omit Domain entirely and the cookie becomes host-only, attached only to the exact host that set it. That is what you want for a session.
What breaks when you reach for it “to be safe.” The trap here works the opposite way from the instinct that reaches for it. Domain=acme.com sends the cookie to app.acme.com, api.acme.com, and marketing.acme.com. A session scoped to your app should never be readable by the marketing subdomain, which might run a CMS with a far lower security bar, the kind of place where one compromised plugin would then hold your users’ sessions. Writing Domain to feel safer is exactly how you widen the area exposed to an attack. The exact-host scope you get by leaving it off is the safer choice.
Set-Cookie: sid=abc123; HttpOnly; Secure; SameSite=Lax; Path=/Set-Cookie: sid=abc123; Domain=acme.comThe first cookie is host-only, so only the host that set it gets it back. The second is handed to every subdomain of acme.com. Reach for the second only when cross-subdomain attachment is the explicit, intended feature.
Max-Age and Expires: how long the cookie lives
Section titled “Max-Age and Expires: how long the cookie lives”What they control. Max-Age is a lifetime in seconds from now, so Max-Age=2592000 is thirty days. Expires is an absolute date in HTTP-date format. When both are present, Max-Age wins, and it is the one you reach for: seconds-from-now has no clock-skew ambiguity, while an absolute date depends on the client’s clock being right.
The session-cookie surprise. Set neither attribute and you have created what the spec calls a session cookie, one that is supposed to live until the browser closes. The problem is that “until the browser closes” rarely holds on the platforms most of your users are on. Mobile browsers, and any browser configured to restore tabs, keep “session” cookies alive across restarts. In practice an unbounded session cookie is closer to permanent than to a single sitting. So rather than rely on tab-close to expire a session, you set Max-Age to the lifetime you actually want, and expiry becomes predictable. The other useful value is Max-Age=0, which deletes the cookie immediately.
The ceiling. Chrome and Firefox cap cookie lifetimes at 400 days (per the cookie spec). Ask for more and the value is silently clamped down. This is worth knowing, so that a “1 year, no wait, 2 years” change does not silently do nothing.
Set-Cookie: sid=abc123; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=2592000__Host- and __Secure-: naming prefixes the browser enforces
Section titled “__Host- and __Secure-: naming prefixes the browser enforces”These are a different kind of mechanism. __Host- and __Secure- are not attributes; they are name prefixes, and the browser treats them as a contract. If a cookie’s name starts with one of these prefixes, the browser checks that the cookie satisfies a set of attribute constraints, and if it does not, the browser rejects the entire Set-Cookie header. The naming convention becomes browser-enforced policy.
What they prevent. The failure mode is a subdomain attacker planting a cookie that the parent domain then trusts. Suppose an attacker controls evil.acme.com. Without prefixes, they could set a cookie with Domain=acme.com and a session-shaped name, and app.acme.com might read it and treat it as a real session. The __Host- prefix closes this off at the browser level: a __Host- cookie cannot carry Domain, so a write from evil.acme.com can never land a __Host-sid cookie that app.acme.com reads. The prefix is a guarantee the browser enforces no matter what the server sends.
The default. Prefix session cookies with __Host- whenever you do not need cross-subdomain attachment, which is most of the time. __Host- is the 2026 default for new code. When you genuinely need a cookie shared across subdomains, __Secure- is the relaxed alternative that lets you set Domain.
The interaction to remember. A __Host- cookie cannot carry Domain: the two are mutually exclusive. You pick one, either host-locked with __Host- or shared with __Secure- plus Domain. You cannot have both.
Set-Cookie: __Host-sid=abc123; HttpOnly; Secure; SameSite=Lax; Path=/Set-Cookie: __Host-sid=abc123; Domain=acme.comThe first is valid: it is host-locked, with all three constraints satisfied. The second is rejected by the browser before it is ever stored, because a __Host- name with a Domain attribute breaks the contract.
Partitioned (CHIPS) and the 2026 third-party-cookie reality
Section titled “Partitioned (CHIPS) and the 2026 third-party-cookie reality”The last attribute only matters in one situation, when your cookie has to work cross-site, and that situation is the part of the web that has changed most in the last few years. So we take the attribute and the surrounding reality together, because the reality is what motivates the attribute.
Partitioned. A Partitioned cookie is keyed not just by its own origin but by the top-level site embedding it. The same widget embedded on news.com and on blog.com ends up with two completely separate cookie jars, and neither can see the other’s. The cookie still works inside each site; it just cannot be the same cookie across sites.
What it prevents. A single shared third-party cookie is exactly the tracking primitive that privacy work has been dismantling: one widget, embedded everywhere, reading the same cookie on every site and stitching your browsing together. Partitioning breaks that by giving each top-level site its own jar. This is why Safari and Firefox already block unpartitioned third-party cookies, and why Partitioned is the path forward rather than a workaround.
The default for the cross-site case. When you ship an embedded widget, a payment iframe, or any legitimate cross-site cookie, the shape is Secure; SameSite=None; Partitioned, with __Host- recommended on top (the canonical CHIPS example uses it).
Set-Cookie: __Host-widget=abc123; Secure; SameSite=None; Partitioned; Path=/The 2026 reading, kept short. Where things stand now is what changes the cookies you can rely on. Safari and Firefox block third-party cookies by default and have for years. Google confirmed in April 2025 that it will not deprecate third-party cookies in Chrome and will not ship a standalone choice prompt; it keeps the controls inside Chrome’s existing privacy settings. In October 2025 Google retired most of the Privacy Sandbox APIs but explicitly continues to support CHIPS, FedCM , and Private State Tokens. The conclusion to carry is this: third-party cookies are not dead in Chrome, but they cannot be relied on, because a growing share of users sit behind settings that block them. Partitioned (CHIPS) is the one cross-site cookie mechanism that survives across all browsers and that Google has committed to. The work of cross-site analytics and ad attribution has mostly moved off third-party cookies and onto first-party data, FedCM, and server-to-server channels. That is out of scope here, but now you know which way it went.
The safe default, assembled and read aloud
Section titled “The safe default, assembled and read aloud”You have walked every attribute. Reassemble them and the destination line from the start of the lesson reads as a sentence:
Set-Cookie: __Host-sid=<value>; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=2592000Host-locked name. The browser enforces Secure, no Domain, and Path=/, so no subdomain can plant this cookie.
Set-Cookie: __Host-sid=<value>; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=2592000No JavaScript read. Even if XSS runs on the page, it cannot read the session out to exfiltrate it.
Set-Cookie: __Host-sid=<value>; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=2592000HTTPS only. The cookie never travels on a plaintext leg where it could be sniffed.
Set-Cookie: __Host-sid=<value>; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=2592000Attached on top-level same-site navigations, withheld on cross-site POSTs. Sign-in links work, and CSRF POSTs fail.
Set-Cookie: __Host-sid=<value>; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=2592000Scoped to the whole app, not the sub-path that happened to set it.
Set-Cookie: __Host-sid=<value>; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=2592000Expires in 30 days, predictably, rather than whenever the browser closes, which on mobile is approximately never.
Read straight through: host-locked name, no JavaScript read, HTTPS only, attached on top-level same-site navigations, scoped to the whole app, expires in thirty days. That is the line. Commit it to memory; you will paste it, in one form or another, into every cookie-writing call you make for the rest of this course.
The default is the starting point. You move off it only when a named feature demands it, and there are exactly four triggers worth memorizing:
- Cross-site embed (widget, payment iframe): drop
SameSite=LaxforSameSite=None; Partitioned, and keepSecure. - The client must read the value (a
themepreference, a double-submit CSRF token): dropHttpOnly. - Cross-subdomain sharing is the feature: drop
__Host-for__Secure-and addDomain. - A legacy HTTP test fixture: drop
Secure. This is rare on this stack.
Everything else stays on the default.
Reading and writing cookies in Next.js
Section titled “Reading and writing cookies in Next.js”You have been reading raw Set-Cookie header strings, because the wire format is the thing to understand. In your app you will rarely hand-write that string. You call a helper, and the helper builds the header for you. In Next.js that helper is cookies().
Start with where it runs. The App Router has three server execution contexts: Server Components, Server Actions, and Route Handlers (you will meet each in depth in later units; here we just name them). Those are the call sites for cookies(). One hard rule up front: in the App Router (Next.js 16), cookies() is async, so the await is not optional.
Here are the three operations. Notice the write call: its option bag maps one-to-one onto the attribute table you just learned.
const sid = (await cookies()).get('__Host-sid')?.value;Works in any server context, whether a Server Component or a Route Handler. await is required because cookies() is async. Returns undefined if the cookie isn’t set.
(await cookies()).set({ name: '__Host-sid', value, httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 60 * 60 * 24 * 30,});Runs in a Server Action or Route Handler. Every key is one row of the attribute table: httpOnly, secure, sameSite: 'lax', path, maxAge (seconds; this is 30 days written so the math reads). Add partitioned: true when you need the CHIPS cross-site cookie.
(await cookies()).delete('__Host-sid');Removes the cookie. Equivalent to setting it with maxAge: 0. Like writing, only valid in a Server Action or Route Handler.
The write call is worth one more pass, because the option-to-attribute mapping is the whole point: if you know the header, you know the option bag.
(await cookies()).set({ name: '__Host-sid', value, httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 60 * 60 * 24 * 30,});Now the one constraint to internalize: cookies can only be written from a context that has not started streaming the response. That means Server Actions and Route Handlers, never a Server Component while it is rendering markup. Call set from a Server Component and it throws. The reasoning (a Set-Cookie header has to be decided before the response body starts flowing) belongs to a later unit; for now, hold the rule: read anywhere on the server, write only in Server Actions and Route Handlers.
There is one more rule that trips people up often, and it falls straight out of the round trip from the start of the lesson.
One word on the client, so you know the boundary. document.cookie reads non-HttpOnly cookies as a single semicolon-separated string. You will not write a document.cookie parser in this course. When the client needs a cookie’s value, read it server-side in a Server Action or Route Handler and pass the value down, rather than parsing the string in the browser.
Practice
Section titled “Practice”Two drills to lock this in. The first tests whether you can look at a real header and name what it does or what it breaks. The second tests the decision the whole lesson is about: given a requirement, which cookie shape do you reach for.
Match each Set-Cookie header to the outcome it produces.
Match each Set-Cookie header to the outcome it produces. Click an item on the left, then its match on the right. Press Check when done.
__Host-sid=…; HttpOnly; Secure; SameSite=Lax; Path=/sid=…; SameSite=None (no Secure)SameSite=None requires Secure.__Host-sid=…; Secure; Path=/; Domain=acme.com__Host- cookie cannot carry Domain.sid=…; Secure; SameSite=Lax; Path=/ (no HttpOnly)document.cookie — the session can be exfiltrated.sid=…; HttpOnly; Secure; SameSite=Lax; Domain=acme.comwidget=…; Secure; SameSite=None (no Partitioned)Partitioned in 2026.Now the decision. Each item is a real requirement; drop it into the cookie shape that requirement calls for.
Each item is a requirement. Sort it into the cookie configuration it calls for. Drag each item into the bucket it belongs to, then press Check.
theme=dark preference the UI toggles and applies.If you want to see a real cookie land in the browser, the optional sandbox below sets the default cookie from a Next.js Route Handler. Hit it, then open DevTools, go to Application, and find the cookie under Cookies. Each attribute shows up in its own column, exactly as the header described.
The sandbox below boots a real Next.js (App Router) dev server in your browser, with no install on your machine. It starts as the empty hello-world template; you add one Route Handler, hit it, and read the cookie jar.
Once the preview is running, create a new file at app/api/cookie/route.ts and paste this in. It is the exact write call from the table above, wrapped in a GET handler that returns a one-line confirmation.
import { cookies } from 'next/headers';
export async function GET() { (await cookies()).set({ name: '__Host-sid', value: 'demo-session-id', httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 60 * 60 * 24 * 30, });
return Response.json({ set: '__Host-sid' });}Then drive the round trip:
- In the preview pane’s address bar, navigate to
/api/cookie. You will see{"set":"__Host-sid"}, which means the handler ran and scheduled theSet-Cookieheader on that response. - Open the preview in its own tab (the “Open in New Tab” control on the preview pane), then open DevTools there → Application → Cookies. Find
__Host-sidand read across its columns:HttpOnly✓,Secure✓,SameSiteLax,Path/. Each column is one attribute from the header: the wire format you just learned, rendered as a table.
External resources
Section titled “External resources”The references below are the ones worth keeping a tab on: the canonical attribute reference, a deep dive on the SameSite attribute, the explainer for Partitioned and CHIPS, and a hands-on look at exactly where SameSite stops protecting you.
The canonical reference for every Set-Cookie attribute and the __Host-/__Secure- prefix rules.
The Chrome team's deep dive on Strict, Lax, and None — and why Lax is the default.
How the Partitioned attribute double-keys a cookie by top-level site, the 2026 cross-site path forward.
PortSwigger's interactive labs showing exactly what SameSite=Lax leaves uncovered.