Login CSRF and Session Fixation
Forcing the victim into the attacker's session, the OAuth state parameter, callback CSRF, and account-linking takeover.
CSRF pointed at the login form
Login CSRF inverts the usual direction. Instead of performing an action as the victim, the attacker logs the victim into the attacker's account.
That sounds harmless — the attacker is giving away access to their own account. It is not, because the victim does not notice, and everything they do next happens in the attacker's account, under the attacker's later control:
- Search and browsing history accumulates in the attacker's account.
- A saved payment method ends up on the attacker's account, usable by them.
- Uploaded documents land in an attacker-owned drive.
- A linked identity — if the victim connects their Google or GitHub account to what they think is their own account, the attacker now has an account that authenticates as the victim's identity provider. That is the takeover variant, and it is the one worth chasing.
The reason login forms are exposed is structural: many frameworks skip CSRF protection on login because there is no session yet to bind a token to. That reasoning is wrong — a pre-session token works fine.
The basic attack
<!doctype html>
<html>
<body>
<!-- The attacker's own, real credentials. Nothing is being stolen at this
point -- the victim is simply being logged in as someone else.
The subtlety that makes this work in practice: many sites show only
an avatar or a first name in the corner, so a victim who was already
logged in may not notice they are now someone else. -->
<form id="poc" action="https://app.example/login" method="POST">
<input type="hidden" name="username" value="attacker@evil.example" />
<input type="hidden" name="password" value="attackers-own-password" />
</form>
<script>document.getElementById('poc').submit()</script>
</body>
</html>Session fixation
The related failure, and the more serious one. Session fixation is when the session identifier does not change across the authentication boundary.
The attack:
- The attacker obtains a session identifier — usually by simply visiting the site and reading their own.
- They plant it in the victim's browser. From a same-site position this is one line of JavaScript; see Subdomain Cookie Injection. It can also be done through a URL parameter if the application accepts session IDs there, or through response splitting.
- The victim logs in normally, with their own credentials.
- The session ID does not change, so the attacker's known identifier is now an authenticated session belonging to the victim.
The attacker ends up holding a valid session for the victim's account. Unlike login CSRF, this is a direct account takeover.
The fix is one line in any framework: regenerate the session identifier on login, and on any privilege change. Rotate the CSRF token at the same time — a token that survives login is its own version of this bug.
OAuth: the state parameter and callback CSRF
<!-- OAuth's callback endpoint is a GET with a code in the query string. If
the flow does not validate `state`, an attacker can feed the victim a
callback carrying the ATTACKER'S authorization code.
Step 1: the attacker begins a real OAuth flow with the provider, gets
as far as the redirect, and captures their own `code` without
letting the browser follow it.
Step 2: they send the victim to the target's callback with that code.
The target exchanges it, gets the ATTACKER's identity from the
provider, and links or logs the victim into the attacker's
account.
A top-level GET navigation, so a Lax cookie rides along and this works
in every browser except Strict. -->
<script>
location = 'https://app.example/oauth/callback?code=ATTACKER_AUTH_CODE'
</script>
<!-- The account-linking variant is the serious one. If the victim is already
logged into their own account and this callback LINKS rather than logs
in, the attacker's identity-provider account is now attached to the
victim's app account -- and the attacker can log in as the victim from
then on, permanently. That is a full takeover and rates far above the
5.4 on this page. -->Regenerating the session
app.post('/login', async (req, res) => {
const user = await authenticate(req.body.username, req.body.password)
if (!user) return res.status(401).send('Invalid credentials')
// The session ID is unchanged from before authentication. Whatever
// identifier the browser was carrying -- including one an attacker planted
// -- is now an authenticated session.
req.session.userId = user.id
// The CSRF token is unchanged too, so an attacker who knew the
// pre-authentication token still knows the post-authentication one.
res.redirect('/dashboard')
})Testing
- Does the login form carry a CSRF token? Delete it and replay. Many applications skip protection here deliberately.
- Does the session ID change on login? Record the session cookie before and after authenticating. If it is identical, that is session fixation, and it is a higher-severity finding than the login CSRF.
- Does the CSRF token change on login? Same test.
- Does the OAuth flow send
state? Look at the authorize redirect. If there is nostateparameter, the callback is forgeable. - Is
stateactually validated? Present is not the same as checked. Remove it, then alter it, and replay the callback both ways. - Is
statesingle-use? Replay a whole valid callback twice. - Does the callback link accounts while authenticated? This is the path to full takeover — check whether hitting the callback as a logged-in user attaches a new identity rather than switching accounts.
- Does logout regenerate? A session ID that survives logout is reusable.
- Is there any "remember me" token that survives regeneration and re-establishes the old identity?
Related
A server-side token bound to the session, plus the lifecycle questions: per-request versus per-session, rotation, BREACH masking, and back-button breakage.
Overwriting the CSRF cookie from a sibling subdomain, why SameSite offers zero protection here, and why __Host- is the fix.
img, script, link, scripted link clicks, and top-level navigation — and why only the last one still carries a cookie.
Making the victim submit the real form with the real token — and why Lax-by-default has quietly killed most framed sessions.
The browser attaches your credentials to any request an attacker can cause. The server sees a valid session and cannot tell who asked for it.
Enumerate, classify by auth mechanism, read the SameSite value first, then test — and write the finding with the browsers named.