Cookie Attributes That Matter

HttpOnly, Secure, SameSite, Domain, Path, Max-Age and the __Host- prefix — what each one actually prevents.

SyntaxSet-Cookie: __Host-sid=<value>; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=1800

A session cookie's security is almost entirely decided by its attributes. Here is every one that matters, and the attack it closes.

AttributeEffectPrevents
HttpOnlyJavaScript cannot read document.cookieXSS stealing the session outright
SecureSent only over HTTPSLeaking over a downgraded connection
SameSiteControls sending on cross-site requestsCSRF
DomainWhich hosts receive itOver-sharing with subdomains
PathWhich paths receive itLittle — it is not a security boundary
Max-Age/ExpiresHow long the browser keeps itIndefinite 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

Example · http
# 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=0

When 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.

Example · javascript
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.

Example · php
<?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

  • Be the first to comment on this lesson.