The attacker does not want your session. They want you to use theirs.
Session fixation is account takeover in reverse: the attacker supplies the session before you log in. How it works, the 2026 Ghost CVE, and the one-line fix.
- security
- vibe coding
- engineering
Every attack on a session that people talk about runs in one direction. The user logs in, the app hands out a token, and someone takes a copy of it. That is session hijacking, and the whole defensive vocabulary follows from it: make the token hard to read, hard to move, short lived, revocable.
Session fixation runs the other way, and that is the only reason it survives review. The attacker does not wait for a token to exist. They arrange for the token to already be in your browser before you type your password, and then let your own login promote it. Nothing is stolen at any point. The credential the attacker ends up holding is one they chose in advance, and your authentication is the step that made it valuable.
Hijacking runs forward, fixation runs backward
The class has its own identifier, CWE-384, and the mechanics collapse into one sentence: if the identifier your app hands to an authenticated user is the same string it was already using for an anonymous one, then anyone who could set the anonymous value now holds an authenticated session.
Most session middleware creates a session on first contact, before anybody has
logged in, because it has to put the shopping cart and the CSRF token and the
“you dismissed this banner” flag somewhere. That anonymous session is fine. The
bug is what happens at the moment of login. If the login handler writes
session.user_id into the session that already existed, the identifier does not
change, and the identifier is the credential.
So the attack has three steps and one of them is done by the victim:
- The attacker causes a session identifier they know to be set in the victim’s browser.
- The victim logs in normally, on the real site, with the real password, and passes whatever second factor is in the way.
- The attacker sends the same identifier and is inside the authenticated session.
Step 2 is what makes this awkward to reason about. There is no failed login to alert on, no impossible-travel signal, no anomalous device. The victim did everything right, and the app did what it was told. This is also why it evades the entire multi-factor budget: MFA guards the transition from anonymous to authenticated, and fixation does not attack that transition, it inherits it.
Nearly eight years of it in a CMS you have heard of
Ghost published GHSA-7mpp-r37j-x5wh (CVE-2026-70594), and the impact statement is one sentence long: “Ghost Admin did not invalidate existing sessions on login which could have allowed for session fixation attacks.” The advisory is candid about the dependency in the next sentence: “Successful exploitation would have required another vulnerability on the same domain where Ghost Admin was hosted.”
The detail worth sitting with is the version range. The advisory says the bug
“is present in Ghost from v2.2.0 to v6.54.0”, fixed in v6.54.1. Check those two
against the registry and the span is not a patch cycle: ghost@2.2.0 was
published in October 2018 and ghost@6.54.1 in July 2026. The bug survived
Ghost 2, 3, 4, 5 and 6. This is not an obscure project, it is not unmaintained,
and the code path in question is the login handler, which is the single most
reviewed function in any application. GitHub scores it 6.7, medium, and it lived
through eight years of releases because the symptom of session fixation is that
everything works perfectly.
Ghost is useful twice, because a commit three months before that release fixed
the adjacent version of the same mistake. The message on
f98cf84f
explains that password change and password reset “destroyed every other active
session for the user, but the originating session was preserved unchanged”,
including its session_id. Read that carefully, because it describes a fix that
looks complete and is not. Revoking every other session is the part everyone
implements. Rotating the one you kept is the part nobody thinks of, and it is
the part that matters if the reason you are resetting the password is that
somebody else set your session for you. The fix, in their words: “Both endpoints
now call req.session.regenerate() and re-assign the verified user to the new
session, so the originating browser keeps its login with a fresh session_id.”
The second reference case shows the other delivery mechanism. Palo Alto Networks’ CVE-2025-0126 describes a session fixation in GlobalProtect: “When configured using SAML, a session fixation vulnerability in the GlobalProtect login enables an attacker to impersonate a legitimate authorized user and perform actions as that GlobalProtect user. This requires the legitimate user to first click on a malicious link provided by the attacker.” No cookie planting, no second vulnerability on the domain. Just a link, and an identifier travelling inside it. Rated 5.6, medium, again.
Getting a token into a browser you do not control
First, where does the attacker get an identifier worth planting? Usually by asking your app for one. Mature session middleware will not adopt an arbitrary string: express-session signs the cookie with your secret and then looks the id up in its store, minting a fresh session when there is no such row, so an invented value fails twice over. The attacker does not need to invent one. They visit your site like anybody else, receive a perfectly valid signed anonymous session, and plant that cookie in the victim’s browser. Step 1 is therefore not the hard part of this attack, and any design leaning on the identifier being unguessable or unforgeable has misread which step those properties protect: they stop an attacker predicting your session, not handing you theirs.
That leaves the delivery. There are three doors, and they are worth separating because only one of them is under your direct control.
A URL parameter. The oldest and most direct: the app accepts a session identifier from the query string, so the identifier can be put in a link. OWASP’s Session Management Cheat Sheet gives the rule in the form of a defensive posture rather than a preference: “A web application should make use of cookies for session ID exchange management. If a user submits a session ID through a different exchange mechanism, such as a URL parameter, the web application should avoid accepting it as part of a defensive strategy to stop session fixation.” Note what that says. Not “do not put session identifiers in URLs”, which everyone already agrees with, but do not accept one that arrives that way, which is a check you have to write.
A cookie set from somewhere else on the domain. This is the door people
misjudge, because they reason about cookies using the same-origin policy, and
cookies do not obey it. A page on blog.example.com can set a cookie with
Domain=example.com, and the browser will then send that cookie to
app.example.com. So a forgotten marketing subdomain, a customer-controlled
subdomain, a stale CNAME pointing at a provider that lets anyone claim the name,
or a compromised static site is enough to write into the cookie jar your app
reads from. This is what the Ghost advisory means by “another vulnerability on
the same domain.”
The defense for that specific door came up in the session hijacking post, and it
is worth restating because it belongs to fixation more than to hijacking: the
__Host- cookie name prefix. A browser will only accept a cookie with that
prefix if it is Secure, has Path=/, and carries no Domain attribute, which
means a sibling subdomain cannot write it at all. That is not a hardening
suggestion, it is the one control that makes step 1 of the attack fail rather
than making step 3 less useful.
A script on the origin. Any JavaScript that runs on your page can write
document.cookie and can call your login flow. This is the door that
stored XSS and DOM XSS
open, and once it is open, fixation is the more durable outcome of the two. A
stolen token dies when the session is revoked. A planted cookie with a long
max-age outlives the revocation, and gets promoted again the next time the
victim logs in, without the attacker needing any further access to the page.
The fix is one line, and it is not “log the user out”
Here is the shape that ships, and it is worth noticing how reasonable it looks:
// Vulnerable: the session object already existed, and this only adds a field
// to it. The identifier in the cookie is unchanged, so whatever value was in
// the browser a second ago is now an authenticated session.
app.post("/login", async (req, res) => {
const user = await verifyPassword(req.body.email, req.body.password);
if (!user) return res.status(401).send("invalid credentials");
req.session.userId = user.id;
res.redirect("/dashboard");
});
And the version that does not:
app.post("/login", async (req, res, next) => {
const user = await verifyPassword(req.body.email, req.body.password);
if (!user) return res.status(401).send("invalid credentials");
// Mint a new identifier before writing anything that depends on identity.
// Anything the anonymous session held (a cart, a redirect target) must be
// read out first and copied across, because regenerate() discards it.
const returnTo = req.session.returnTo;
req.session.regenerate((err) => {
if (err) return next(err);
req.session.userId = user.id;
req.session.returnTo = returnTo;
req.session.save((saveErr) => (saveErr ? next(saveErr) : res.redirect("/dashboard")));
});
});
The express-session docs
describe regenerate plainly: “Once complete, a new SID and Session instance
will be initialized at req.session.” Their own logout example carries the
reason as a comment, “regenerate the session, which is good practice to help
guard against forms of session fixation,” and their login example calls it too.
The API has been there the whole time. The Ghost bug was not a missing feature,
it was a missing call.
Three rules generalize past Express, and all three come straight from the OWASP sheet.
Regenerate at every privilege change, not just at login. The requirement is stated without qualification: “The session ID must be renewed or regenerated by the web application after any privilege level change within the associated user session.” Login is the obvious one. Password change and password reset are the ones Ghost missed in April. Assuming an elevated role, completing a step-up verification, and accepting an invitation to another workspace are all the same moment, which is the point at which the identifier stops representing what it represented a second ago. If your app has an “assume this role” path, that is a privilege escalation surface and a fixation surface at the same time.
Use a different cookie name before and after authentication. OWASP
recommends you “use a different session ID or token name (or set of session IDs)
pre and post authentication, so that the web application can keep track of
anonymous users and authenticated users without the risk of exposing or binding
the user session between both states.” This is the cheap structural version of
the fix: if the authenticated cookie is __Host-session and the anonymous one is
cart, then no amount of writing to cart can produce an authenticated
session, and the failure mode of forgetting to regenerate is downgraded from
account takeover to a stale cart.
Give the new identifier real entropy. “Session identifiers must have at
least 64 bits of entropy to prevent brute-force session guessing attacks.” A
regenerate that produces a predictable value has moved the attack from fixation
to guessing rather than removing it.
On a Supabase app the attack points the other way
Everything above assumes a server that sets cookies. Most AI-built apps do not have one, and on the stack that Lovable and Bolt generate by default, the classic attack is not available: there is no server-side session object to fixate, because the browser client holds the tokens itself.
What exists instead is the mirror image, and it is worth naming because it does
not look like a session bug at all. Read the client defaults, which are visible
in DEFAULT_OPTIONS in the auth-js source:
// supabase/auth-js, GoTrueClient.ts
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: true,
flowType: 'implicit',
Three of those combine into a primitive. detectSessionInUrl is documented as
“automatically detect OAuth grants in the URL and sign in the user.”
persistSession means “save the user session into local storage.” And the
implicit flow is the one where, in Supabase’s own words, “the access and refresh
tokens are contained in the URL fragment”, arriving as
https://yourapp.com/...#access_token=<...>&refresh_token=<...>.
That fragment is also why the redirect URI is worth auditing on the same afternoon: under this default, the address the authorization server delivers to receives a token pair rather than a code, so a wildcard left in the redirect allowlist leaks a renewable session instead of a string that needs a verifier.
Put those together and a link is enough to sign a visitor in. Not into their
account: into the attacker’s. The attacker signs into their own account on your
app, reads their own token pair straight out of localStorage (it is their
browser, so nothing is being defeated here), and sends the victim a link to your
real domain with those tokens in the fragment. The client’s _isImplicitGrantCallback
returns true for any URL carrying an access_token, so it picks them up, stores
them, and renders a perfectly normal signed-in dashboard belonging to somebody
else.
The reason this is worth a section is that the damage runs backwards from what people expect. Nothing of the victim’s is read. Instead, everything they do next is deposited into an account the attacker can open at their leisure: the document they write, the file they upload, the API key they paste into settings, the payment method they add, the integration they connect. It is the same category as the login-CSRF case in the CSRF post, which is the general form of “an action ran as the wrong identity”, and it is fixation’s true counterpart on a client-side stack. The attacker did not take your session. They gave you one.
Two things fix it, and the first is a one-word config change:
export const supabase = createClient(url, anonKey, {
auth: {
// The URL now carries a short-lived `code`, not a token pair. Exchanging it
// requires the verifier this browser generated and stored when it started
// the flow, so a link crafted anywhere else has nothing to exchange with.
flowType: "pkce",
},
});
PKCE closes the door twice, and both closures are visible in GoTrueClient.ts
rather than inferred. First, the thing in the URL stops being a credential: in
Supabase’s description, “the code parameter is commonly known as the Auth Code
and can be exchanged for an access token by calling exchangeCodeForSession(code)”,
and _isPKCECallback will not even treat a URL as a callback unless the browser
also holds a stored <storageKey>-code-verifier. A code minted in the attacker’s
browser has no matching verifier in the victim’s, so there is nothing to exchange
with. Second, and this is the part that actually matters for the attack above,
_getSessionFromURL runs an explicit mismatch check: a URL bearing an
access_token handed to a client configured for PKCE throws
AuthPKCEGrantCodeExchangeError("Not a valid PKCE flow url."). So the config
change does not merely add a safer flow beside the old one, it makes the client
refuse the fragment.
That refusal is exactly what OWASP’s “avoid accepting a session ID from a URL parameter” rule is reaching for. It is worth noticing that the default configuration does the opposite of that rule, on purpose, because the implicit flow predates PKCE and still has to work.
The second fix is not code, it is layout: show which account is signed in, where the user will look before doing something irreversible. Forced login is only dangerous for as long as the victim does not notice whose account they are in, and an email address in the corner of the header cuts the window to the first glance. Anything that spends money, exports data, or connects a third party should re-state the identity in the confirmation rather than assume it.
While you are auditing that flow, there is a second reason not to leave tokens in URLs, and Supabase states it themselves as a design constraint of the implicit flow: “GET requests and their full URLs are often logged.” That is the same failure path as the HAR file story in session hijacking, except the value in the URL here is a refresh token, which is renewable rather than expiring.
What a scanner can see, and what it cannot
Be precise about this, because the honest answer is more useful than a broader one. Session fixation is a behavioral property: proving it requires observing an identifier before a login and the same identifier after it, which means holding credentials and logging in twice. A passive scan of a deployed URL, which is what our runtime layer performs, does none of that and cannot report on it. Neither can a secrets scan or a dependency scan. This is a code review item and, better, a test item: assert that the cookie value changes across the login response, and the regression can never come back quietly.
What a passive scan does grade is the door. Our free scanner reads the
Set-Cookie headers on the page it fetched, and the three findings map onto the
fixation chain rather than only the theft one:
cookie-not-secure, HIGH: “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.” The same unencrypted request is where an identifier gets written, not just read.cookie-not-httponly, 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.” A script that can read it can also set it.cookie-no-samesite, LOW, because the consequence is a forged action rather than a stolen credential.
Two properties of that check are worth repeating, since they decide whether the
number means anything. It only grades cookies whose name looks like a session
(sess, sid, auth, token, login, user, jwt, csrf, remember), so
a locale preference does not generate three findings and bury the one that
matters. And when a page sets no session cookie at all, the three checks are
recorded as skipped, not passed, because a site that never issued a session
has not earned three green ticks. You can run the same check on a live URL with
the cookie security checker.
The design-level answer is more interesting than the check, and our own control
plane is a convenient example of it because it is not clever. There is no
anonymous session at all. Nothing is created on first contact, so there is no
pre-authentication identifier in existence to fixate. A successful login inserts
a row into user_login_history and the bearer token handed to the browser is
that row’s id, minted at that moment, which means rotation is not a call
somebody has to remember to make, it is the only thing the code can do. The
same structure decides the reset case that Ghost had to patch separately: a
completed password reset closes every open session for the user and returns no
token at all, so the person who just reset the password logs in again and
receives a session id that did not exist five seconds earlier.
That is a stricter trade than Ghost’s (they keep the originating browser signed in with a fresh identifier, which is friendlier), and either is defensible. What is not defensible is the third option, the one both of these were fixing: keep the browser signed in and keep the old identifier.
A session you were handed is not a session you own
The reason fixation stays in codebases for four major versions is that it has no symptom. Nothing fails, nothing looks slow, no log line is unusual, and the login you would inspect works correctly for every user who tries it. The bug is in what did not happen: a string that should have changed did not.
That also makes it one of the few security properties you can settle permanently. Rotation is not a control that degrades, needs tuning, or trades off against usability. You either mint a new identifier at the moment identity changes or you do not, and the test that proves it is three lines long and runs in a millisecond.
So the question to ask about your own login handler is narrower than usual, and it has a yes or no answer. Take the session identifier your app sends to the browser immediately before the password is checked, and the one it sends immediately after. If those two strings are equal, you do not have a session that was created for an authenticated user. You have an anonymous one that got promoted, and somebody else may have chosen it.