How HTTP Works

Response header

< Set-Cookie: session=abc123; Secure; HttpOnly; SameSite=Lax

Set-Cookie Header: Attributes, Examples and Browser Rules

Set-Cookie syntax and every attribute: Expires, Max-Age, Domain, Path, Secure, HttpOnly, SameSite, Partitioned, __Host- prefixes, with Express and Django.

Reviewed 4 min readintermediate4 sourcesTry itMarkdown
Direction
Response
Category
Cookies
Spec
RFC 6265 §4.1
JS can read it
No: forbidden response header
On this page

TL;DR: Set-Cookie tells the browser to store a name=value pair and replay it in Cookie on matching requests. For a session cookie use HttpOnly; Secure; SameSite=Lax; Path=/, and prefer the __Host- name prefix.

Syntax

HTTP/1.1 200 OK
Set-Cookie: __Host-sid=a1b2c3d4; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=86400

The browser stores it and later sends:

GET /dashboard HTTP/1.1
Host: example.com
Cookie: __Host-sid=a1b2c3d4

One cookie per header. Send several Set-Cookie lines, never one comma-joined line.

Attributes

AttributeEffect
Max-Age=NLifetime in seconds from now. 0 or negative deletes the cookie. Takes precedence over Expires
Expires=<HTTP-date>Absolute expiry in GMT. Dates in the past delete the cookie
Domain=example.comAlso sends the cookie to all subdomains. Omit it to restrict to the exact host that set it
Path=/URL path prefix the cookie applies to. It is not a security boundary
SecureOnly sent over HTTPS (browsers also treat localhost as secure for this purpose)
HttpOnlyHidden from document.cookie, still sent on requests. Limits damage from XSS, does not prevent it
SameSite=Strict|Lax|NoneControls sending on cross-site requests. None requires Secure
PartitionedOpts into CHIPS: the cookie is keyed to the top-level site that embedded it

Expiry. A cookie with neither Max-Age nor Expires is a session cookie, but “deleted when the browser closes” is unreliable: Chrome’s “Continue where you left off” and similar restore features keep session cookies alive. Use an explicit short Max-Age for anything with security meaning. RFC 6265bis limits lifetime to 400 days and Chrome clamps longer values.

Pick expiry dates well in the future when you need an absolute date, for example:

Set-Cookie: theme=dark; Path=/; Expires=Wed, 01 Dec 2027 12:00:00 GMT; SameSite=Lax

A copy-pasted Expires from an old tutorial is already in the past, and the cookie is deleted the moment it is set.

SameSite and “site”

“Site” means the registrable domain (eTLD+1): app.example.com and api.example.com are same-site, even though they are different origins. example.com and example.co.uk are not. A request is cross-site when the top-level site differs.

When SameSite is absent, Chromium-based browsers treat the cookie as Lax, with a short exception that allows it on top-level cross-site POSTs for the first two minutes. Firefox and Safari do not apply that Lax default, though Safari blocks third-party cookies outright and Firefox partitions them. Always write the attribute explicitly. SameSite=None; Secure is required for cookies in third-party contexts (embedded widgets, cross-site iframes), and many browsers now block or partition those anyway. Details in SameSite.

Prefixes and Partitioned

Name prefixes are enforced by the browser:

Set-Cookie: __Host-sid=abc; Path=/; Secure; HttpOnly; SameSite=Lax
Set-Cookie: __Secure-pref=1; Domain=example.com; Secure

__Secure- requires Secure and an HTTPS origin. __Host- additionally requires Path=/ and no Domain, so a compromised or unrelated subdomain cannot overwrite your session cookie. A cookie violating the prefix rules is silently rejected.

Partitioned (CHIPS) gives embedded third-party widgets cookie state that is separate per top-level site:

Set-Cookie: __Host-widget=xyz; Path=/; Secure; SameSite=None; Partitioned

Limits that bite

  • About 4096 bytes per cookie (name plus value plus attributes); browsers drop larger ones silently. Practical per-domain count limits are around 180 in Chrome and Firefox.
  • All matching cookies are sent on every request, including static assets. Large cookies produce 431 Request Header Fields Too Large or nginx 400 Request Header Or Cookie Too Large.
  • The Secure flag cannot be set from an HTTP page, and an HTTP response cannot overwrite a Secure cookie in current browsers.

By stack

Express’s maxAge is milliseconds, Django’s max_age and Flask’s max_age are seconds, and Next.js cookies().set takes seconds. Mixing these up is the most common reason a cookie lasts 1000 times too long or too short.

from flask import make_response

@app.route('/login')
def login():
    response = make_response('Logged in')
    response.set_cookie(
        'sessionId', 'abc123',
        max_age=86400, secure=True, httponly=True, samesite='Lax'
    )
    return response

nginx can add cookies and pass them through, and proxy_cookie_flags rewrites flags on upstream cookies:

proxy_cookie_flags ~ secure httponly samesite=lax;
curl -si https://example.com/login -d 'user=a&pass=b' | grep -i '^set-cookie'
curl -s -c jar.txt -b jar.txt https://example.com/login -d 'user=a&pass=b'
cat jar.txt

In Chrome DevTools, Application, then Cookies shows stored cookies; the Network panel marks rejected ones with a warning icon on the Set-Cookie header and a reason such as “This attempt to set a cookie via a Set-Cookie header was blocked because it had the Secure attribute but was not received over a secure connection.” Typical causes: Secure over HTTP, SameSite=None without Secure, Domain not matching the host, a __Host- rule violation, or a size over 4096 bytes. See Cookie not sent.

In practice

Express.js
// Session cookie (no maxAge: removed when the browser session ends)
res.cookie('sessionId', token, {
  httpOnly: true,
  secure: true,
  sameSite: 'lax',
  path: '/'
})

// Persistent cookie (30 days). Express maxAge is in MILLISECONDS
res.cookie('rememberMe', userId, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict',
  maxAge: 30 * 24 * 60 * 60 * 1000
})

// Clear: name, path and domain must match the original
res.clearCookie('sessionId', { path: '/' })
Next.js App Router
import { cookies } from 'next/headers'

export async function POST() {
  const cookieStore = await cookies()

  cookieStore.set('sessionId', token, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    path: '/',
    maxAge: 60 * 60 * 24 // seconds
  })

  return new Response(null, { status: 204 })
}
Django
from django.http import JsonResponse

def login_view(request):
    response = JsonResponse({'status': 'ok'})
    response.set_cookie(
        'rememberMe',
        value=user_id,
        max_age=30 * 24 * 60 * 60,  # seconds
        httponly=True,
        secure=True,
        samesite='Strict',
    )
    return response

Frequently asked questions

What is Set-Cookie?

Set-Cookie is a response header that asks the browser to store a cookie and send it back in a Cookie header on later matching requests. Each header carries one cookie plus optional attributes such as Max-Age, Path, Secure, HttpOnly and SameSite.

Which attributes should a session cookie have?

HttpOnly so JavaScript cannot read it, Secure so it only travels over HTTPS, SameSite=Lax (or Strict) to limit cross-site sending, and Path=/. Adding the __Host- name prefix additionally requires Secure, Path=/ and no Domain, which prevents sibling subdomains from overwriting it.

Does Max-Age or Expires win when both are set?

Max-Age wins. RFC 6265 section 5.3 says a user agent that sees both uses Max-Age. Old Internet Explorer ignored Max-Age, which is why some frameworks still emit both.

How long can a cookie last?

RFC 6265bis caps the lifetime at 400 days, and Chrome enforces that cap: a longer Max-Age or Expires is clamped. Other browsers may apply their own limits, for example Safari restricts the lifetime of cookies set from JavaScript.

Can I set multiple cookies in one response?

Yes, send one Set-Cookie header per cookie. Set-Cookie is the exception to the rule that repeated fields can be comma-joined (RFC 9110 section 5.3), because the Expires date contains a comma. Proxies and frameworks must keep them as separate lines.

How do I delete a cookie?

Send Set-Cookie with the same name, Domain and Path and either Max-Age=0 or an Expires date in the past. If any of name, Domain or Path differ, the browser treats it as a different cookie and the original stays.

Sources

  1. MDN Web Docs: Set-Cookiedeveloper.mozilla.org
  2. RFC 6265bis: Cookies: HTTP State Management Mechanism (draft)datatracker.ietf.org
  3. RFC 6265 section 4.1: Set-Cookie (original specification)rfc-editor.org
  4. RFC 9110 section 5.3: Field Order (Set-Cookie exception)rfc-editor.org

Keep going

Browse /search