← Back to Blog

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

· 13 min read Secure, HttpOnly, SameSite: the three words missing from most session cookies

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

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.

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:

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:

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:

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

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.

← Back to Blog

Accessibility Options

Text Size
High Contrast
Reduce Motion
Reading Guide
Link Highlighting
Accessibility Statement

J.A. Watte is committed to ensuring digital accessibility for people with disabilities. This site conforms to WCAG 2.1 and 2.2 Level AA guidelines.

Measures Taken

  • Semantic HTML with proper heading hierarchy
  • ARIA labels and roles for interactive components
  • Color contrast ratios meeting WCAG AA (4.5:1)
  • Full keyboard navigation support
  • Skip navigation link
  • Visible focus indicators (3:1 contrast)
  • 44px minimum touch/click targets
  • Dark/light theme with system preference detection
  • Responsive design for all devices
  • Reduced motion support (CSS + toggle)
  • Text size customization (14px–20px)
  • Print stylesheet

Feedback

Contact: jwatte.com/contact

Full Accessibility StatementPrivacy Policy

Last updated: April 2026