Nobody stole the password, and the account was still gone
Session hijacking is account takeover with no password and no MFA prompt. How sessions leak, what Secure, HttpOnly and SameSite really buy, and how to kill one.
- security
- vibe coding
- engineering
Authentication is a question your app asks once. The session is the answer it keeps repeating for every request that follows, and it repeats it to whoever shows up holding the token. That is not a flaw in the design, it is the design: a session token is a bearer credential, and bearer means exactly what it says.
Which is why session hijacking is the attack that skips everything you spent your effort on. The password policy, the breach-check on sign-up, the hardware key, the one-time code: all of it guards a single moment, and none of it is consulted again afterward. An attacker who obtains a live session has not defeated your login. They have arrived after it, holding the receipt.
The login flow does not protect what it hands out
Once the token exists, four properties decide what it is worth to a thief, and all four are decided outside the login form.
Where it is stored determines who can read it: a cookie with the right
attributes is unreachable from JavaScript, a value in localStorage is
readable by every script on the origin, and a token in a URL is readable by
your access logs.
How long it lives determines whether a copy found tomorrow still works. Supabase is explicit about its own answer: a session is created when a user signs in and “by default, it lasts indefinitely and a user can have an unlimited number of active sessions on as many devices.”
Whether it is bound to anything determines whether it works from the attacker’s laptop. By default, nothing binds it. The token is valid from any IP, any browser, any country.
Whether you can revoke it determines what you can do after you find out. If the session is a signed JWT with no server-side row behind it, the honest answer is that you can wait for it to expire, which is the bill described in JWT expiration.
None of those is a bug you can find by reading the login handler. They are defaults, and the attack surface of a session is entirely made of defaults.
The best-documented theft in recent memory was a support ticket
Between 28 September and 17 October 2023, an attacker read files that customers had uploaded to Okta’s support case management system. Okta’s own root cause write-up is worth reading in full, and the sentence that matters here is this one: “Some of these files were HAR files that contained session tokens which could in turn be used for session hijacking attacks.”
A HAR file is a debugging artifact. It is what a browser exports when you click
“save all as HAR” in the network panel, and support engineers ask for it because
it is the fastest way to see what actually went over the wire. That is precisely
the problem: what went over the wire includes the Cookie header on every
request, which is the session, in plaintext, in a file the customer emailed to
their vendor in good faith.
Cloudflare’s account of the same incident describes the outcome in one line: the attacker “was able to hijack a session token from a support ticket which was created by a Cloudflare employee.” That token carried administrative privileges in Cloudflare’s Okta instance, and the attacker used it on 18 October. No password was involved at any point. No multi-factor prompt was triggered at any point, because none of this was a login. 1Password and BeyondTrust reported the same shape independently.
The root cause behind the root cause is almost funny in how ordinary it is: “the username and password of the service account had been saved into the employee’s personal Google account.” A synced browser profile, a support attachment, a session token. Three mundane things.
Five doors, and the last one is not yours to close
Sessions leave in five ways, and it is worth separating them because the fixes are unrelated to each other.
The network. A cookie without the Secure attribute is sent over plain
http, so one unencrypted request on a shared network is enough. This is the
oldest door in the list. It is also the one that closes on its own once TLS is
everywhere, which is why it feels historical, and why it is still worth checking
on a preview deployment or an internal admin tool that nobody put behind HTTPS.
A script on the page. Any JavaScript running on your origin can read
localStorage, document.cookie and the URL. That script does not have to be
an injection you wrote: it can arrive through a compromised dependency, an
analytics tag, a chat widget, or a stored payload rendered into a page a moment
later. This is the door that stored XSS and
DOM XSS open.
Logs and exports. The HAR case above, plus every place a URL travels. A
token in a query string ends up in browser history, in access logs, and in the
Referer header of same-origin requests, which is the leak path traced in
detail in
password reset poisoning.
Another site. Not theft exactly, but use: a page the victim visits causes
their browser to send authenticated requests to your app. The attacker never
sees the token and still acts as the user, which is
CSRF. Chrome has treated a cookie with no SameSite
attribute as SameSite=Lax since Chrome 80, so this door is narrower than it
used to be rather than open, and the CSRF post is about the exceptions that
browser default still leaves you.
The device itself. Infostealer malware copies cookie databases wholesale. Nothing in your application code prevents this, which is exactly why the industry’s answer to it had to come from the browser. That answer is the last section of this post.
Three attributes, and what each one actually buys
Here is what a session cookie looks like when the framework was not told otherwise, which is what a generated backend tends to emit:
// Vulnerable: three defaults, all of them wrong for a session.
res.setHeader("Set-Cookie", `session_id=${sessionId}; Path=/`);
And here is the version (Express) that closes two of those doors outright and narrows a third:
// The __Host- prefix is not decoration. A browser will only accept a cookie
// with that name if it is Secure, has Path=/, and carries no Domain attribute,
// so a compromised subdomain cannot overwrite the session cookie of the parent.
res.cookie("__Host-session", sessionId, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 1000 * 60 * 60 * 8,
});
Our free scanner reads the Set-Cookie headers on the page it fetched and
grades those three attributes separately, because they stop different things and
are worth different amounts:
cookie-not-secureis a HIGH. The finding text says why in one sentence: “The browser will send this cookie over plain http as well as https, so a single unencrypted request is enough for someone on the network to capture the session and become that user.”cookie-not-httponlyis a MEDIUM: “Any script that ends up running on the page, including one injected through a third-party dependency, can read the cookie and take over the session.”cookie-no-samesiteis a LOW, because the consequence is a forged action rather than a stolen credential.
Two details of how that check works are worth knowing, because they are the
difference between a number you can act on and a number that flatters you. It
only grades cookies whose name looks like a session (sess, sid, auth,
token, login, user, jwt, csrf, remember), so a theme preference does
not generate three findings and bury the one that matters. And when a site sets
no session cookie at all, the three checks are recorded as skipped, not
passed. A marketing page that never issues a session has not earned three
green ticks, and telling it that it did would be the exact free pass our
checks_run bookkeeping exists to refuse. You can run the same check on a live
URL with the cookie security checker.
Now the honest limits, because HttpOnly in particular gets oversold.
HttpOnly does not stop an XSS from acting as the user. A script on your page
can issue fetch("/api/transfer", ...) with credentials attached and the browser
will send the cookie it refuses to show the script. What HttpOnly prevents is
exfiltration: the attacker cannot take a copy of the credential away and use
it later, from their own machine, after the tab is closed. That is a real and
large difference, and it is the whole distinction between an incident that ends
when the victim navigates away and one that continues for a week. It is not the
same thing as being safe from XSS, and the review that actually reduces that
risk is the sink review in the DOM XSS post, plus a policy you can validate with
the CSP validator.
The transport findings sit underneath all of this. no-https is a CRITICAL
in our catalog because everything else is moot: on a plain http page, “anyone on
the same network, and every network in between, can read passwords and session
cookies as they go past.” no-hsts is a MEDIUM, and its finding text is
about a narrower window that people tend to dismiss: without it “a browser will
still try http first on the next visit, which leaves a window for someone on the
network to intercept the request before the redirect happens.” One request over
http with a non-Secure cookie attached is all the attack needs.
On a generated app, the session is usually not in a cookie at all
Most AI-built apps never reach the code above, because they do not have a
backend that sets cookies. The Supabase browser client holds the session, and
persistSession means what it says: “save the user session into local storage.”
Supabase’s own session documentation is direct about the gap, noting that for
HTTP-only cookies “the Supabase JavaScript libraries provide only limited
support.”
So on the stack that Lovable and
Bolt projects ship by default, HttpOnly, Secure
and SameSite are not weakly configured, they are not in the picture. The
access token and the refresh token beside it are readable by any script on the
origin. That is the trade already described from two other directions, in
the CSRF post (cross-site request forgery goes away
because there is no ambient cookie to ride) and in
JWT expiration (nothing gives you a cancel
button). Session hijacking is the third face of the same trade, and the specific
consequence is this: the credential a script can steal here is not one session,
it is a renewable one.
Which makes refresh token rotation the control that carries the weight on this stack. Supabase rotates refresh tokens and detects reuse, with a 10 second interval to survive races and flaky connections, and their documentation is unusually candid about which threat that is for. It guards against cases “where a refresh token could have been stolen from the user, for example by exposing it accidentally in logs that leak (like logging cookies, request bodies or URL params) or via vulnerable third-party servers.” Read that list again next to the HAR file story. It is the same list.
Reuse detection is worth understanding as a detector, not a wall. It cannot tell the thief from the user, so the only correct response when one token is presented twice is to end the whole session family and make both parties sign in again. That is inconvenient exactly once, for the real user, and terminal for the other one.
If you want cookies back on this stack, @supabase/ssr moves the session into
cookies set by your server. That is a real architectural change, not a config
flag, and it is worth doing when you have a server rendering authenticated pages
anyway. It is not worth faking with a hand-rolled copy of the token into a
cookie you also read from JavaScript, which gives you the storage of a cookie
and the exposure of localStorage.
Rotate on the way in, expire on the way out, be able to kill it
Four server-side properties. None of them is difficult, and a generated auth flow routinely ships without the first and the last.
Regenerate the identifier at every privilege change. OWASP’s Session Management Cheat Sheet states it as a requirement: “The session ID must be renewed or regenerated by the web application after any privilege level change within the associated user session.” The attack this closes is the mirror image of everything above. Instead of stealing your session, the attacker gives you one of theirs (through a link carrying a session identifier, or a cookie planted from a subdomain they control) and waits for you to log in with it. If the identifier survives the login, they are now inside your authenticated session without ever having touched it. Logging in, changing a password, and assuming an elevated role are all moments that must mint a new identifier and discard the old one.
Give it real entropy. The same cheat sheet puts the floor at 64 bits:
“Session identifiers must have at least 64 bits of entropy to prevent
brute-force session guessing attacks.” A UUIDv4 carries 122 random bits and
clears it comfortably. A counter, a hash of the user id, or anything derived
from data the attacker also knows does not clear it at all.
Expire on two clocks. An idle timeout ends sessions that stopped being used, which is what limits a token found later in a HAR file or a shared laptop. An absolute timeout ends sessions that have simply gone on too long, which is the only thing that bounds a hijack nobody noticed. Idle alone is not enough, because an attacker actively using a session keeps it alive forever.
Keep a row you can delete. This is the property that turns a bad day into a recoverable one, and it is the one a stateless JWT gives up. In our own control plane a session is a row, and the token handed to the browser is that row’s id, looked up on every request:
-- Abridged: the real query lists every column with an alias, because both
-- tables expose an `id` and a wildcard decode cannot tell the two apart.
-- What matters is the WHERE clause. The lookup fails closed three ways at
-- once (no row, a closed row, an expired row) and the JOIN adds a fourth,
-- since a deleted user takes their live sessions with them.
SELECT h.id, h.user_id, h.expires_at, u.email, u.email_verified
FROM user_login_history h
JOIN users u ON u.id = h.user_id
WHERE h.id = $1
AND h.logout_at IS NULL
AND h.expires_at > now();
Revocation is then one statement, and the reason it exists is the reason every app eventually needs it. A completed password reset closes every open session for that user in the same transaction that writes the new hash:
UPDATE user_login_history SET logout_at = greatest(now(), login_at)
WHERE user_id = $1 AND logout_at IS NULL;
A reset that leaves the attacker’s session alive has not recovered the account,
it has changed a password the attacker no longer needs, which is the argument
made at length in
password reset poisoning. The
greatest(now(), login_at) is not cosmetic: now() is the transaction’s start
time, so a session opened by a concurrent request a millisecond later would
otherwise violate the logout_at >= login_at constraint and abort the entire
reset.
One trade worth naming rather than glossing: that row id is stored as-is, so
anyone who can read the sessions table holds live tokens. Reset tokens in the
same service are stored as SHA-256 hashes for exactly this reason. Session rows
expire and can be closed en masse, which is why the weaker treatment is
defensible, but if your threat model includes a leaked backup or an over-broad
read policy, storing a hash of the token and looking it up by hash removes the
question. While you are there: the same UPDATE above, exposed as a “sign out
everywhere” button, gives the user that control directly, and it is the only
recovery step available to someone who suspects their laptop was compromised.
Binding the session to something the thief does not have
Everything above shortens the window. None of it stops a stolen token from working inside that window, because the token proves possession of itself and nothing else. Closing that requires binding the session to a property the attacker cannot copy along with the cookie.
The obvious candidates are weak, and OWASP says so plainly: binding to IP address or User-Agent means “a skilled attacker can bypass these controls by reusing the same IP address,” by “sharing the same network (very common in NAT environments), or by manually modifying the User-Agent.” Worse, both produce false positives on ordinary behavior: a phone changes IP every time it moves between Wi-Fi and cellular, and a browser changes User-Agent when it updates itself. Treat them as detection signals, worth logging and worth alerting on, never as an authorization check that hard-fails.
The version that works is coarser and honest about its trade. After the 2023 incident, Okta shipped “session token binding based on network location as a product enhancement to combat the threat of session token theft against Okta administrators. Okta administrators are now forced to re-authenticate if we detect a network change.” Note the scope: administrators, where re-authenticating occasionally is cheap, and the consequence of a stolen session is total. Scoping a friction-heavy control to the sessions that can do the most damage is the generalizable idea, and it applies just as well to a small app: an admin session can be short, single-purpose, and re-verified, even when the user session is not.
The proper fix has to be in the browser, and it is now shipping. Chrome’s Device Bound Session Credentials binds a session to a key the device cannot export: “The browser generates a public-private key pair, storing the private key securely using hardware-backed storage such as a Trusted Platform Module (TPM) when available.” The cookie itself becomes short-lived, and when it expires the browser proves device possession with a signed challenge to a refresh endpoint before receiving a new one. The property that matters is the one Google states directly: even if session cookies are stolen, “they cannot be used from another device.”
Google announced on 28 May 2026 that DBSC is generally available in Chrome for Windows, framing it as protection that holds even in the worst case: “Even if malware was present on the user’s device, DBSC reduces the risk of session theft and makes it meaningfully more difficult for malicious actors to exploit stolen session cookies.” That is the fifth door, the one no amount of application code could reach.
Be realistic about the near term. It is one browser on one operating system as of this writing: general availability landed on Windows, where the key lives in a TPM, and macOS support using the Secure Enclave is planned with no shipping date. Adopting it means implementing a refresh endpoint rather than setting an attribute, so it is not a checkbox for a weekend project. It is, however, the direction of travel, and it tells you what the other controls are actually for: they exist because the token is currently unbound, and their job is to keep the value of a stolen one low until it is not.
The front door is not where the account is lost
Session hijacking is unsatisfying to work on because there is no single fix to point at. There is a set of small properties, each individually easy, that together decide whether a copied string is worth anything: where it is stored, whether a script can read it, whether it travels only over TLS, how long it lives, whether it survives a privilege change, and whether you can delete it after the fact.
The reason to fix them is that this is the attack that pays best. Every other route to an account has to get past whatever you put at the login: the password, the second factor, the rate limit, the lockout. A stolen session gets past all of it by arriving later, and the victim sees nothing, because from your server’s point of view nothing unusual happened. Someone presented a valid token, and your app did what it was built to do.
So the question to ask about your own app is not how hard it is to log in. It is what a copy of one live session, taken from a support attachment or a compromised laptop, is still worth an hour from now. If the answer is “everything, until it expires, and it does not expire,” that is the finding.