Cookie Attributes That Matter
HttpOnly, Secure, SameSite, Domain, Path, Max-Age and the __Host- prefix — what each one actually prevents.
Set-Cookie: __Host-sid=<value>; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=1800A session cookie's security is almost entirely decided by its attributes. Here is every one that matters, and the attack it closes.
| Attribute | Effect | Prevents |
|---|---|---|
HttpOnly | JavaScript cannot read document.cookie | XSS stealing the session outright |
Secure | Sent only over HTTPS | Leaking over a downgraded connection |
SameSite | Controls sending on cross-site requests | CSRF |
Domain | Which hosts receive it | Over-sharing with subdomains |
Path | Which paths receive it | Little — it is not a security boundary |
Max-Age/Expires | How long the browser keeps it | Indefinite session lifetime |
HttpOnly is a mitigation, not a cure
It stops an XSS payload from exfiltrating the cookie. It does not stop the payload from using it — injected JavaScript can still call your API from the victim's browser, cookie attached. Treat HttpOnly as damage control and fix the XSS.
Domain widens, it never narrows
Omitting Domain gives you a host-only cookie: dfg.com only. Setting Domain=dfg.com is broader — it sends the cookie to api.dfg.com, blog.dfg.com and every other subdomain. Default to omitting it; add it only when you genuinely need sharing, and remember that an XSS on any subdomain then reaches your session.
You cannot set a cookie for a domain you do not control. dfg.com cannot set a cookie on abc.com — that single rule is why cross-site cookie auth is hard, and it is the subject of the cross-origin category.
Path is not a security boundary
Path=/admin looks like isolation but is not: same-origin JavaScript from /anything can reach into /admin's context. Use it for tidiness (a refresh token scoped to /auth/refresh), not for protection.
Session vs persistent
No Max-Age or Expires means a session cookie — gone when the browser closes (though "continue where you left off" features often restore them). With Max-Age it survives restarts: that is your "remember me".
The __Host- prefix
Naming a cookie __Host-sid makes the browser enforce that it is Secure, has Path=/, and has no Domain. That last part is the valuable bit: a compromised subdomain cannot overwrite it. It is one rename with a real security gain.
Example
# First-party session, same site as the frontend — the safe default
Set-Cookie: __Host-sid=8f2b...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=1800
# Shared with subdomains (api.dfg.com, app.dfg.com) — wider blast radius
Set-Cookie: sid=8f2b...; HttpOnly; Secure; SameSite=Lax; Domain=dfg.com; Path=/
# Cross-site (abc.com frontend calling dfg.com API) — None REQUIRES Secure
Set-Cookie: sid=8f2b...; HttpOnly; Secure; SameSite=None; Path=/; Max-Age=1800
# Refresh token narrowed to the one endpoint that needs it
Set-Cookie: rt=9c1e...; HttpOnly; Secure; SameSite=Strict; Path=/auth/refresh; Max-Age=2592000
# Deleting: same name, same Path/Domain, expiry in the past
Set-Cookie: __Host-sid=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0When to use it
- A marketing subdomain gets compromised, but the session cookie is host-only with the __Host- prefix, so the attacker cannot read or overwrite it.
- A 'remember me' checkbox switches the cookie from a browser-session cookie to a 30-day persistent one, with a longer-lived session record to match.
- A refresh token is scoped to Path=/auth/refresh so it is not attached to every ordinary API call, shrinking where it can leak from.
More examples
Setting it correctly in Express
clearCookie with mismatched attributes is a classic bug: the browser treats it as a different cookie and the original session cookie survives logout.
const isProd = process.env.NODE_ENV === 'production';
res.cookie('__Host-sid', sid, {
httpOnly: true, // JS cannot read it
secure: true, // HTTPS only — required by the __Host- prefix
sameSite: 'lax', // sent on top-level navigations, not on cross-site XHR
path: '/', // required by the __Host- prefix
maxAge: 30 * 60 * 1000, // 30 minutes
// NO domain — required by the __Host- prefix, and the safer default anyway
});
// "Remember me" is the same cookie with a longer life, plus a longer session row
res.cookie('__Host-sid', sid, {
httpOnly: true, secure: true, sameSite: 'lax', path: '/',
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
});
// Deleting must repeat path (and domain, if you set one) or the browser keeps it
res.clearCookie('__Host-sid', { path: '/', secure: true, sameSite: 'lax' });The same settings in Laravel
session()->regenerate() is the one line people forget; without it an attacker who plants a known session id before login still holds a valid session after it.
<?php
// config/session.php
return [
'driver' => 'redis',
'lifetime' => 30, // minutes, idle
'expire_on_close' => false,
'cookie' => 'sid',
'path' => '/',
'domain' => null, // host-only: do NOT widen unless needed
'secure' => true, // HTTPS only
'http_only'=> true, // no JS access
'same_site'=> 'lax', // 'none' only for genuine cross-site use
];
// Session id rotation on login is what stops session fixation.
public function login(Request $request)
{
$request->validate(['email' => 'required|email', 'password' => 'required']);
if (! Auth::attempt($request->only('email', 'password'), $request->boolean('remember'))) {
return response()->json(['error' => 'invalid_credentials'], 401);
}
$request->session()->regenerate(); // new id, old one invalidated
return response()->json(['user' => Auth::user()]);
}
Discussion