Dev.to Security 🔐 Cybersecurity 👁 0 📖 8 min read

Cookie Security Flags: Auditing Secure, HttpOnly, SameSite

Most session hijacking bugs I find in audits are not exotic. They are a session cookie missing one attribute: HttpOnly left off so an XSS payload can read it, Secure missing so it rides over a plain HTTP request, SameSit

Cookie Security Flags: Auditing Secure, HttpOnly, SameSite

Most session hijacking bugs I find in audits are not exotic. They are a session cookie missing one attribute: HttpOnly left off so an XSS payload can read it, Secure missing so it rides over a plain HTTP request, SameSite=None set "to fix a login bug" and never reverted, or a Domain=.example.com scope that hands the cookie to every forgotten subdomain. Each takes one line to fix and about two minutes to audit, if you know what to look for in the Set-Cookie header.

I build PixelValut, a set of browser-side developer and security tools, and I run this exact audit on every Laravel and Node app I ship. Below is the workflow with real header output, the attack each flag stops, and a checklist you can paste into a pull request template.

Which cookie security flags should a session cookie have?

Direct answer: A session cookie should be set with Secure, HttpOnly, SameSite=Lax (or Strict), Path=/ and no Domain attribute, ideally using the __Host- name prefix, which the browser only accepts when those conditions hold. Add a short Max-Age or session lifetime, and regenerate the session ID on login to prevent fixation.

What each attribute does and what it stops

Attribute What the browser does Attack it limits Common mistake
Secure Sends the cookie only over HTTPS Interception on HTTP requests Missing on apps that "redirect to HTTPS anyway"
HttpOnly Hides the cookie from document.cookie Session theft through XSS Turned off so a front-end can read a token
SameSite=Lax Withholds the cookie on most cross-site subrequests and POSTs CSRF on state-changing requests Set to None globally to fix one embed
SameSite=Strict Withholds it on all cross-site requests, including top-level links CSRF, cross-site leaks Users appear logged out after clicking an email link
Domain= Shares the cookie with all subdomains Nothing; it widens scope .example.com exposes it to every subdomain
Max-Age / Expires Persists beyond the browser session Limits the replay window when short Sessions valid for a year
__Host- prefix Rejected unless Secure, Path=/ and no Domain Subdomain cookie injection and overwrite Not used because the framework default name is fixed
__Secure- prefix Rejected unless Secure Overwrite from HTTP pages Assumed to imply HttpOnly (it does not)

Without a SameSite attribute, Chromium treats a cookie as Lax, but other browsers have differed; set it explicitly.

Step 1: Read the raw Set-Cookie headers with curl

Start with a header-only request against the login page or home page. These are real responses from this morning, with values redacted:

$ curl -sI https://github.com/ | grep -i '^set-cookie'
set-cookie: _gh_sess=<redacted>; path=/; HttpOnly; secure; SameSite=Lax
set-cookie: _octo=<redacted>; expires=Sun, 31 Oct 2027 05:23:32 GMT; domain=.github.com; path=/; secure; SameSite=Lax
set-cookie: logged_in=no; expires=Sun, 31 Oct 2027 05:23:32 GMT; domain=.github.com; path=/; HttpOnly; secure; SameSite=Lax

$ curl -sI https://pixelvalut.com/ | grep -i '^set-cookie'
Set-Cookie: XSRF-TOKEN=<redacted>; expires=Sat, 31 Oct 2026 07:23:36 GMT; Max-Age=7200; path=/; secure; samesite=lax
Set-Cookie: pixelvalut_session=<redacted>; expires=Sat, 31 Oct 2026 07:23:36 GMT; Max-Age=7200; path=/; secure; httponly; samesite=lax

How I read them:

  • _gh_sess is a textbook session cookie: host-only (no Domain), HttpOnly, Secure, Lax, and no Expires, so it dies with the browser session.
  • _octo and logged_in are scoped to .github.com on purpose: non-secret state shared across subdomains, fine for a flag, never for a session ID.
  • XSRF-TOKEN on PixelValut, a Laravel app, is deliberately not HttpOnly. It is the CSRF token that the JavaScript client reads and echoes back in the X-XSRF-TOKEN header. That is by design; the secret is the session cookie next to it.
  • pixelvalut_session has Secure, HttpOnly, Lax and a two-hour Max-Age. What it lacks is the __Host- prefix, which is the next hardening step I cover below.

If you prefer the browser, the Security Headers Checker shows the response headers alongside CSP and HSTS, and my guide on auditing HTTP security headers covers the rest of that response.

Step 2: Confirm what the browser actually stored in DevTools

In Chrome or Edge, open DevTools, go to Application > Storage > Cookies and select the origin. In Firefox it is Storage > Cookies. Check four columns:

  1. HttpOnly and Secure ticked for every session or auth cookie.
  2. SameSite shows Lax or Strict, not blank or None.
  3. Domain shows the exact host (no leading dot) for session cookies.
  4. Expires / Max-Age shows "Session" or a short lifetime.

Then open the Network tab, click the login request and look at the response's Cookies sub-tab. A yellow warning icon next to a cookie means the browser blocked or modified it, and hovering shows why, for example "This Set-Cookie was blocked because it had the SameSite=None attribute but did not have the Secure attribute". That message explains most "works in Postman, not in the browser" login tickets.

A steel padlock and a small key on a wooden table, representing locked-down session cookies

Step 3: Test the scenarios that actually get exploited

Session fixation

If the session ID stays the same before and after login, an attacker who can plant a known ID in the victim's browser inherits the authenticated session. Test it:

# 1. Get an anonymous session cookie
curl -s -c jar.txt -o /dev/null https://app.example.test/login
grep session jar.txt | awk '{print $7}'   # note the value
# 2. Log in with the same jar
curl -s -b jar.txt -c jar.txt -o /dev/null -X POST https://app.example.test/login \
  -d '[email protected]&password=...&_token=...'
grep session jar.txt | awk '{print $7}'   # must be different

The fix is to regenerate the ID on every privilege change. In Laravel, $request->session()->regenerate() after Auth::attempt() (the starter kits already do this); in Express with express-session, call req.session.regenerate() before setting the user.

CSRF through a permissive SameSite

SameSite=Lax blocks the cookie on cross-site POST form submissions and on cross-site fetches, which neutralizes most classic CSRF. It still sends the cookie on top-level GET navigations, so any state-changing GET endpoint (/logout, /delete?id=, /approve?invoice=) remains exposed. Audit routes for that, and keep a CSRF token on forms anyway; SameSite is a second layer, not a replacement.

If you genuinely need SameSite=None (a cross-site iframe), set it only on that cookie, always with Secure.

Subdomain cookie injection

A cookie set with Domain=.example.com from blog.example.com, or from a compromised marketing subdomain, can shadow or overwrite the session cookie of app.example.com. The __Host- prefix closes this: the browser refuses a __Host- cookie that has a Domain attribute, a path other than /, or no Secure flag, so no subdomain can plant one.

Set-Cookie: __Host-session=<value>; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=7200

In Laravel that is a config change, SESSION_COOKIE=__Host-app_session, SESSION_DOMAIN=null, SESSION_SECURE_COOKIE=true in .env. In Express:

app.use(session({
  name: '__Host-sid',
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: { httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 2 * 60 * 60 * 1000 }
}));
app.set('trust proxy', 1); // behind a TLS-terminating proxy, or secure cookies are never sent

The trust proxy line is the failure I see most in Node deployments: behind Nginx or a load balancer, Express thinks the request is HTTP and silently refuses to set the Secure cookie.

Tokens in readable cookies

If your SPA stores a JWT in a non-HttpOnly cookie or in localStorage, any XSS can exfiltrate it. Decode one with the JWT Decoder and check its lifetime; my article on JWT security pitfalls explains why I prefer an HttpOnly session cookie for first-party apps.

Severity framework for findings

Finding Severity I assign Why
Session cookie without HttpOnly High Any XSS becomes account takeover
Session cookie without Secure on an HTTPS site High One HTTP request leaks it
Session ID not regenerated on login High Enables session fixation
SameSite=None on the session cookie Medium to high CSRF depends entirely on tokens
Domain=.example.com on the session cookie Medium Every subdomain receives it
No __Host- prefix Low Hardening against subdomain injection
Session lifetime of weeks or more Low to medium Larger replay window

The 10-point cookie audit checklist

  1. Run curl -sI on the login page, the post-login redirect and one API route.
  2. List every cookie and mark which ones are secret (session, refresh, remember-me).
  3. Secret cookies: Secure and HttpOnly present.
  4. Secret cookies: SameSite=Lax or Strict, set explicitly.
  5. Secret cookies: no Domain attribute; consider the __Host- prefix.
  6. Lifetime matches policy; remember-me tokens are separate and rotate.
  7. Session ID changes after login and after privilege changes.
  8. No state-changing GET routes; CSRF tokens on forms.
  9. DevTools Network tab shows no blocked-cookie warnings.
  10. Behind a proxy, the app trusts X-Forwarded-Proto so Secure cookies are actually sent.

Frequently asked questions

Does HttpOnly prevent XSS?

No. It prevents JavaScript from reading the cookie, which stops the most common XSS payoff, session theft. An attacker can still act inside the victim's session while the page is open, so you still need output encoding and a Content Security Policy.

Is SameSite=Lax enough to stop CSRF?

It blocks the cookie on cross-site POSTs and subrequests, which covers most CSRF. It still sends the cookie on top-level GET navigations, so state-changing GET routes stay exposed. Keep CSRF tokens on forms and treat SameSite as a second layer.

Why is my Secure cookie not being set in production?

Usually because a reverse proxy terminates TLS and the app sees plain HTTP. Configure the framework to trust the proxy's X-Forwarded-Proto header, such as Express's trust proxy setting or Laravel's TrustProxies middleware, and the cookie will be issued.

What is the difference between __Host- and __Secure- cookies?

A __Secure- cookie must have the Secure flag. A __Host- cookie must also have Path=/ and no Domain attribute, which locks it to one exact host. Neither implies HttpOnly or SameSite.

Should the CSRF token cookie be HttpOnly?

Not when the pattern requires JavaScript to read it, as with Laravel's XSRF-TOKEN cookie that the client echoes in a request header. The token is not a credential on its own; the session cookie it pairs with must be HttpOnly.

Can I audit cookies without logging in?

Partly. Anonymous responses show the session cookie's attributes, since most frameworks issue one on the first visit. For fixation and post-login cookies, use a test account, never a real user's session.

Follow & Connect

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.