# Secure, HttpOnly, SameSite: the three words missing from most session cookies

A PHPSESSID without Secure turns the Mega Analyzer cookie row red and opens most penetration test reports. What the row reads from Set-Cookie, and the fix per framework.

Author: J.A. Watte
Published: September 21, 2026
Source: https://jwatte.com/blog/blog-mega-analyzer-cookie-flags/

---

The Trust / Security Headers section of the Mega Analyzer has one row for cookies. On a clean site it reads **Cookies set with Secure, HttpOnly and SameSite (2)**, with the count of cookies it graded in the parentheses. On a site that is not clean it reads **1 of 2 cookie(s) set without Secure / HttpOnly / SameSite: PHPSESSID (no Secure)**, and it is red. What it is really measuring is whether the server that answers an anonymous first visit attaches the three attributes that keep a session cookie from being read over plain http, read by an injected script, or sent along with a request that another site started. Two of the three cost nothing to add. The third takes one decision.

## What the check actually tests

The analyzer's desktop fetch goes through the site's fetch proxy as an anonymous visitor carrying no cookies of its own, and the row reads the `Set-Cookie` header off the final response after redirects. That word "final" matters: a cookie set by an intermediate hop, say the http:// to https:// bounce, is never seen. The proxy collects multiple `Set-Cookie` headers into one string joined with a comma, which is the same thing a browser's `Headers.get()` does, and that creates a parsing trap the analyzer has to handle, because `Expires=Wed, 21 Oct 2026 07:28:00 GMT` contains a comma too. The parser splits only before a `name=` token that is not a weekday, so the date stays inside its cookie.

For each cookie it records the name, whether `Secure` and `HttpOnly` are present, the `SameSite` value lowercased, plus Domain, Path, Max-Age and Expires. Then it asks four questions, and any yes puts the cookie in the failing list:

- **No Secure**: the attribute is absent. This applies to every cookie, session or not.
- **No HttpOnly on a session-looking cookie**: the name contains `sess`, starts or ends with `sid`, or contains `phpsessid`, `jsessionid`, `connect.sid`, `wordpress_logged_in`, `laravel`, `auth`, `token` or `login`. A cookie named `theme` without HttpOnly passes this question; `PHPSESSID`, `ASP.NET_SessionId`, `JSESSIONID`, `connect.sid` and `laravel_session` do not.
- **No SameSite**: the attribute is absent.
- **SameSite=None without Secure**: the combination Chrome, Edge and Firefox refuse outright.

The title names up to four failing cookies with their reasons in parentheses. PHP's own defaults for `session.cookie_secure`, `session.cookie_httponly` and `session.cookie_samesite` are 0, 0 and empty, so a site that never touched them prints **PHPSESSID (no Secure, no HttpOnly, no SameSite)**; a host's php.ini may already have flipped one, which is how you get the shorter **PHPSESSID (no Secure)** version. The row is pass or fail with nothing in between, and a fail counts as an issue in the Full Summary.

Three things do not trip it. A page that sets no cookie at all passes as **Cookies set with Secure, HttpOnly and SameSite (no cookies set on first visit)**; this site is in that state, since a static Netlify page sends nothing. Cookies written by JavaScript after the page loads, which is where analytics, consent banners and chat widgets keep theirs, never appear, because the analyzer reads response headers and not `document.cookie`. And the value of `SameSite` is not validated: `SameSite=Nope` counts as present, so it passes here, while the [Cookie Flags Audit](/tools/cookie-flags-audit/) marks it invalid. That tool is the deeper pass on the same header. It also grades lifetime, Domain scope and the `__Host-` and `__Secure-` prefix rules, none of which the Mega row reads.

One blind spot sits upstream of the analyzer. A CDN or page cache serving a stored copy can hand out one with no `Set-Cookie` on it, so a page behind full-page caching can read as cookie-free on a cache hit. The Mega row takes the first response as it comes; the Cookie Flags Audit refetches with a cache-busting query when the first response carries no cookie, and says so when the second one does.

## Why it matters

Take the three attributes in the order an attacker would.

Without `Secure`, the cookie goes out on any `http://` request to that host. MDN defines the attribute as the instruction that the cookie "is sent to the server only when a request is made with the https: scheme", and the draft cookie standard tells a user agent to include a Secure cookie in a request "only if the request is transmitted over a secure channel". Drop the attribute and one plain-http image on an old page, or one link typed without the scheme on a network someone else controls, carries the session identifier in clear text. HSTS narrows that window for returning visitors and is the companion fix, but the attribute is what closes it for the cookie itself.

Without `HttpOnly`, any script on the page can read the cookie. MDN: the attribute "forbids JavaScript from accessing the cookie, for example, through the Document.cookie property", and the cookie is still sent on `fetch()` calls, so nothing legitimate breaks. What it prevents is the cheapest kind of account takeover. A compromised third-party tag, a review form that echoes input unescaped, one line of `document.cookie` posted to a server the attacker owns, and whoever holds that string is that user until the session expires. OWASP's session management guidance describes HttpOnly as the instruction that browsers not allow scripts to reach the cookie through `document.cookie`, and calls that protection mandatory for session cookies. The Mega row only enforces it on session-looking names because a preference cookie that a script needs to read is a legitimate design; the session identifier is not.

Without `SameSite`, you have not decided, and browsers decide differently. The draft standard puts an absent attribute under a default enforcement "equivalent to Lax", with an allowance for cookies set in the last two minutes to ride along on a cross-site POST. MDN's compatibility table shows who actually does that: Chrome since 80 and Edge since 86 treat the missing attribute as Lax, Firefox only behind a preference, and Safari not at all. So the same login cookie is withheld from a cross-site request in Chrome and sent in Safari, and the difference stays invisible until a form on a partner site works in one browser and not the other. OWASP asks that session cookies "explicitly set SameSite=Strict (preferred) or SameSite=Lax". The fourth question is the sharpest: for a cookie marked `SameSite=None` without `Secure`, the draft says to "ignore the cookie entirely", and Chrome 80, Edge 86 and Firefox 131 do. The symptom is a login that loops forever, because the cookie the server sent was never stored.

There is also a lifetime rule the Mega row does not read but you should know, because it explains a support ticket. Since Chrome 104, per Chrome's own announcement, a cookie asking for an expiry "further out than 400 days" is not rejected; "their expiration date is set to 400 days instead". The draft standard now says a user agent "MUST limit the maximum age of the cookie" and recommends that cap for both Expires and Max-Age. A "remember me for ten years" cookie is a 400-day cookie, quietly.

Why this is usually the first line on a penetration test report is simple: it costs one request to find. No login, no exploit, no crawling. A tester runs `curl -sI` against the homepage, reads the `Set-Cookie` line, and has a finding before coffee. The cyber-insurance questionnaires I have filled out ask the same question in plainer words. Fixing it before either of them looks is cheaper than explaining it.

## How to fix it

**Step 1**: find out who sets the cookie. One request tells you:

```
curl -sI https://example.com/ | grep -i set-cookie
```

A `PHPSESSID` or `laravel_session` is your application. An `ASP.NET_SessionId` or `.AspNetCore.Session` is your application. A `JSESSIONID` is a Java container. A `connect.sid` is Express. Names like `__cf_bm`, `AWSALB` or `ARRAffinity` come from a CDN or load balancer in front of you, and their flags are that vendor's to set; the Cookie Flags Audit labels those as set by a CDN or WAF and points you at that dashboard instead of your code.

**Step 2**: set all three where the cookie is born, so every environment gets them. PHP, in `php.ini` or a `.user.ini`, or with `ini_set()` before `session_start()`:

```
session.cookie_secure = 1
session.cookie_httponly = 1
session.cookie_samesite = Lax
```

`session.cookie_samesite` exists from PHP 7.3. For cookies you set yourself, the array form of `setcookie()` takes the same three keys:

```php
setcookie('prefs', $value, [
  'expires' => 0, 'path' => '/',
  'secure' => true, 'httponly' => true, 'samesite' => 'Lax'
]);
```

ASP.NET Core, in `Program.cs`. The ordering rule is the part people miss: Microsoft's guidance says to call `UseCookiePolicy` before `UseAuthentication` "or any method that writes cookies", otherwise the policy never touches the cookies that matter.

```csharp
builder.Services.Configure<CookiePolicyOptions>(o =>
{
    o.Secure = CookieSecurePolicy.Always;
    o.HttpOnly = HttpOnlyPolicy.Always;
    o.MinimumSameSitePolicy = SameSiteMode.Lax;
});
// ...
app.UseCookiePolicy();
app.UseAuthentication();
```

Express with `express-session`. `httpOnly` already defaults to true there; `secure` and `sameSite` do not, and `secure: true` behind a reverse proxy needs `trust proxy` or the app will think every request is plain http and never set the cookie:

```js
app.set('trust proxy', 1);
app.use(session({
  name: '__Host-sid',
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: { secure: true, httpOnly: true, sameSite: 'lax', path: '/', maxAge: 8 * 60 * 60 * 1000 }
}));
```

**Step 3**: when you cannot change the application (a plugin, a vendor app, a CMS you only host), add the flags at the proxy. nginx 1.19.3 and later has a directive for exactly this. Order matters, because the documentation says "the first matching directive will be chosen", so the exemption for a cookie a script must read goes first and the catch-all last:

```nginx
proxy_cookie_flags consent_prefs secure samesite=lax;
proxy_cookie_flags ~ secure httponly samesite=lax;
```

Apache with `mod_headers` does the same with an edit rule. A cookie that already carries a flag gets it twice, which browsers accept, so tighten the expression if you want it clean:

```apache
Header always edit Set-Cookie ^(.*)$ "$1; Secure; HttpOnly; SameSite=Lax"
```

WordPress sits between the two cases: core sets its auth cookies HttpOnly, and Secure once the site runs on https, but it adds no SameSite, and every plugin that calls `setcookie()` picks its own flags. The server rule covers all of them at once.

**Step 4**: rename the session cookie with the `__Host-` prefix. Section 4.1.3.2 of the draft standard says a cookie whose name begins with `__Host-` "will have been set with a Secure attribute, a Path attribute with a value of /, and no Domain attribute", and a browser refuses one that is not. That turns a configuration promise into something the browser enforces, so a future deploy that drops `Secure` fails loudly instead of silently. OWASP recommends the prefix for session identifiers and, separately, a generic name like `id` in place of the framework default, since `PHPSESSID` tells a scanner what you run. `__Host-id` does both. A cookie that must be shared with a subdomain cannot take `__Host-`; `__Secure-` is the fallback and only pins the Secure attribute.

**Step 5**: re-run the Mega Analyzer, then widen. The [Cookie Flags Audit](/tools/cookie-flags-audit/) grades every cookie on the same first visit for the three flags plus prefix rules, lifetime and Domain scope. Its fix prompt prints each `Set-Cookie` line as received and as it should read, then the PHP, WordPress, Laravel, ASP.NET, Express, Java, nginx, Apache, Cloudflare Worker and Netlify snippets, with the block for the stack it detected first. The [Security Headers Audit](/tools/security-headers-audit/) covers `Strict-Transport-Security`, which is the header that keeps the http:// request from happening in the first place, and prints the header block in your host's syntax. If the same run also flagged HTML-side findings, the [Code-Diff Patch Generator](/tools/code-diff-patch-generator/) writes those as unified diffs; cookie attributes live in framework or server configuration, so that part stays with the snippets above.

## When to leave it alone

A cross-site request forgery token in the double-submit pattern is meant to be read by script. Laravel's `XSRF-TOKEN` is the common case: the framework sets it so that Axios and Angular can read it and copy it into the `X-XSRF-TOKEN` header, which means it cannot carry HttpOnly, and because the name contains `token`, the Mega row reports it as **XSRF-TOKEN (no HttpOnly)**. Keep `Secure` and `SameSite` on that cookie, leave HttpOnly off, and accept the red row. Adding HttpOnly to make the row green breaks every Axios or Angular request that relied on reading it, while plain HTML forms with the hidden `_token` field keep working, which is what makes the breakage easy to miss.

`SameSite=None` is correct, not a defect, for a cookie that has to travel inside a third-party iframe: an embedded booking widget, a payment form, a support chat that keeps its own session. The row only objects when None arrives without Secure. Set Secure and the pair passes.

A `theme` or `lang` preference cookie without HttpOnly already passes the row, because the HttpOnly question only applies to session-looking names. Adding it anyway is fine if no script reads the value, and pointless if one does.

Cookies set by a CDN, WAF or load balancer in front of the origin (`__cf_bm`, `AWSALB`, `ARRAffinity`) carry the flags the vendor chose. If one of these is the only failing cookie, the fix is a vendor setting or a support ticket, not your application.

And if the row reads "no cookies set on first visit", do not go looking for a cookie to harden. The best session cookie on a brochure site is the one that is never set. The finding only becomes real on the page where a login form or a cart sets one, so run the Cookie Flags Audit on that page, not the homepage.

## Fact-check notes and sources

- **Source**: https://datatracker.ietf.org/doc/draft-ietf-httpbis-rfc6265bis/ (draft 22, "Cookies: HTTP State Management Mechanism") establishes the `__Host-` and `__Secure-` prefix rules in sections 4.1.3.1 and 4.1.3.2, that a cookie with a same-site-flag of None is ignored "unless the cookie's secure-only-flag is true", that an absent or unknown SameSite value falls under a default enforcement "equivalent to Lax" (or Lax-allowing-unsafe), that a Secure cookie is included in a request only over a secure channel, and that a user agent "MUST limit the maximum age of the cookie" with a recommended limit of 400 days for both Expires and Max-Age.
- **Source**: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie establishes the Secure, HttpOnly and SameSite definitions quoted above, that a `SameSite=None` cookie "must also" set Secure, that "some browsers use Lax as the default value if SameSite is not specified", and the two-minute POST allowance when Lax is applied as a default. Its browser compatibility table, built from the mdn/browser-compat-data project, gives Chrome 80 and Edge 86 for the Lax default with Firefox behind a preference and Safari not supporting it, and Chrome 80, Edge 86 and Firefox 131 for rejecting None without Secure.
- **Source**: https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html establishes the Secure and HttpOnly attribute descriptions (HttpOnly protection "is mandatory"), that "session cookies must explicitly set SameSite=Strict (preferred) or SameSite=Lax", the `__Host-` prefix as "Recommended for session IDs", and the advice to rename the default session ID "to a generic name, such as id".
- **Source**: https://developer.chrome.com/blog/cookie-max-age-expires establishes that "as of Chrome release M104 (August 2022) cookies can no longer set an expiration date more than 400 days in the future" and that such cookies "aren't rejected; their expiration date is set to 400 days instead".
- **Source**: https://www.php.net/manual/en/session.configuration.php establishes the `session.cookie_secure` and `session.cookie_httponly` defaults of `0`, `session.cookie_samesite` (empty default, available from PHP 7.3.0), and `session.name` defaulting to `PHPSESSID`.
- **Source**: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_cookie_flags establishes the directive's syntax, that it appeared in version 1.19.3, the `secure`, `httponly` and `samesite=` flags, the `~` regular-expression form, and that "the first matching directive will be chosen".
- **Source**: https://learn.microsoft.com/en-us/aspnet/core/security/samesite establishes `CookiePolicyOptions.MinimumSameSitePolicy`, `SameSiteMode.Unspecified` for omitting the attribute, and that `UseCookiePolicy` must be called before `UseAuthentication` or any method that writes cookies.
- **Source**: https://expressjs.com/en/resources/middleware/session.html establishes express-session's `cookie.httpOnly` default of true, `cookie.secure` and `cookie.sameSite` defaults of false, the `trust proxy` requirement for `secure: true` behind a proxy, and the default cookie name `connect.sid`.
- **Source**: https://laravel.com/docs/12.x/csrf establishes that Laravel "stores the current CSRF token in an encrypted XSRF-TOKEN cookie" and that libraries like Angular and Axios "automatically place its value in the X-XSRF-TOKEN header", which is why that cookie is readable by script.
- The row wording, the session-name pattern and the parser behavior are from the Mega Analyzer source as of 2026-09-21; the cache-busting refetch and the CDN/WAF labeling are from the Cookie Flags Audit source on the same day; the "no cookies" result for this site is from a `curl -sI` against it on the same day.

## Related reading

- [Security Headers as a Trust Signal: How AI Engines Score HSTS, CSP, and Referrer-Policy](/blog/blog-tool-security-headers-audit/)
- [Why CSP Strictness Audit Exists](/blog/blog-tool-csp-strictness-audit/)
- [Why Cookie + Storage Drift Audit Exists](/blog/blog-tool-cookie-storage-drift-audit/)
- [Why Mixed Content Audit Exists](/blog/blog-tool-mixed-content-audit/)

If you build client sites for a living, the three attributes belong in your project template rather than on your checklist, because a template cannot forget; The $20 Dollar Agency treats that kind of default as the difference between a site you hand over and one you keep getting called about.

*This post is informational, not legal advice. Mentions of third parties are nominative fair use. No affiliation is implied.*


---

Canonical HTML: https://jwatte.com/blog/blog-mega-analyzer-cookie-flags/
RSS: https://jwatte.com/feed.xml
JSON Feed: https://jwatte.com/feed.json
Hero image: https://jwatte.com/images/blog-mega-analyzer-cookie-flags.webp
