# 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](https://pixelvalut.com/tools/?utm_source=hashnode&utm_medium=article&utm_campaign=cookie-security-flags-secure-httponly-samesite), 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:

```text
$ 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](https://pixelvalut.com/tools/security-headers-checker?utm_source=hashnode&utm_medium=article&utm_campaign=cookie-security-flags-secure-httponly-samesite) shows the response headers alongside CSP and HSTS, and my guide on [auditing HTTP security headers](https://dev.to/muhammadalimughal/how-to-audit-http-security-headers-csp-hsts-more-10c3) 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](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/xs7f4a8gz5kfvnyy0bp6.jpg)

## 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:

```bash
# 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=test@example.test&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.

```text
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:

```js
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](https://pixelvalut.com/tools/jwt-decoder?utm_source=hashnode&utm_medium=article&utm_campaign=cookie-security-flags-secure-httponly-samesite) and check its lifetime; my article on [JWT security pitfalls](https://dev.to/muhammadalimughal/json-web-token-jwt-security-pitfalls-alg-none-weak-hmac-secrets-and-client-side-token-1gp) 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

- **Website:** [PixelValut Tools](https://pixelvalut.com/tools/?utm_source=hashnode&utm_medium=article&utm_campaign=cookie-security-flags-secure-httponly-samesite)
- **YouTube:** [PixelValut (@pixel-valut)](https://www.youtube.com/@pixel-valut)
- **TikTok:** [PixelValut (@pixelvalut007)](https://www.tiktok.com/@pixelvalut007)
- **GitHub:** [Muhammad Ali Mughal (@ali111887)](https://github.com/ali111887)
- **LinkedIn:** [Muhammad Ali Mughal](https://www.linkedin.com/in/muhammad-ali-m-03434a120/)
