The Login Link That Let Anyone In
GET /auth/verify?token=a3f9c2d1e84b&redirect=/dashboard HTTP/2
→ 302 Found
Set-Cookie: session=a3f9c2d1e84b; Path=/; HttpOnly
The token in the URL and the session cookie were the same value.
That was the problem.
How the Login Flow Worked
The platform offered a passwordless login option. Users entered their email address, the platform sent a link, and clicking that link authenticated the user without a password. A clean user experience, widely adopted, and in this case, fatally flawed.
The link looked like this:
https://platform.example/auth/verify?token=a3f9c2d1e84b
When a user clicked it, the server verified the token was valid, issued an authenticated session, and redirected to the dashboard. The platform then set a session cookie using the same token value from the URL.
It was that last step — reusing the URL token as the session identifier — that broke everything.
The Fixation Point
A valid session token has to come from somewhere. In most applications, the server generates a fresh token at authentication time, stores it server-side, and sends it to the browser in a cookie. The client never chooses or influences the session ID.
But this platform generated the session token before authentication, embedded it in the login link, and kept using it after the user logged in. The token was fixed at link-generation time.
This meant two things:
First: Anyone who saw the URL before the user clicked it — in a Slack message, a forwarded email, a screenshot, or a server access log — already had the session token. Not a pre-authentication token that would expire. The actual post-authentication session identifier.
Second: There was nothing stopping an attacker from generating their own login link request, extracting the token before the victim used it, and waiting.
Constructing the Attack
The platform's magic link flow began with a POST request:
POST /auth/request-link HTTP/2
Content-Type: application/json
{ "email": "victim@example.com" }
The response was a success message with no useful information. The link went to the victim's email. But it turned out the platform was generating the token deterministically from a combination of the email address, a timestamp rounded to the nearest minute, and a server-side seed that had not been rotated.
That was one problem. The more immediately exploitable problem was simpler.
An attacker who could observe the login link — through monitoring a shared inbox, social engineering, or simply requesting a link to an email address they controlled and studying the token format — could:
- Generate a login request for the victim's account
- Intercept or predict the token
- Do nothing — just wait
When the victim clicked the link and logged in, their session was bound to the token the attacker already knew. No interception of live traffic. No man-in-the-middle. The attacker simply used the known token:
GET /api/account/profile HTTP/2
Cookie: session=a3f9c2d1e84b
→ 200 OK
{ "email": "victim@example.com", "balance": "...", "2fa": false }
Full account access. The victim was logged in. The attacker was also logged in. As the same user.
The Token in the URL
Beyond session fixation, placing the session identifier in a URL creates secondary exposure vectors that persist long after the user closes their browser.
Browser history. Every browser stores the full URL of visited pages. The login link — containing the session token — sits in the victim's history. Anyone with local access to the device, or a browser extension with history permissions, could extract it.
Server access logs. Web servers and load balancers log the full request URL by default. The session token appearing in access logs means it was captured by every logging system the request passed through — the application server, the CDN, the WAF, third-party log aggregation services.
Referrer headers. If the authenticated session page contained any third-party resources — analytics, fonts, advertising pixels — the browser would send the authentication URL in the Referer header of those sub-requests. The session token would be transmitted to those third parties.
Link previews. Pasting the login link into a chat application that generates URL previews would cause the chat service's server to fetch the URL, potentially consuming the one-time token or logging it.
The platform was aware that tokens in URLs were not ideal. The URL token was supposed to be a single-use value — valid only until it was first used for authentication, after which the session would operate on a separate, long-lived cookie. That was the design. The implementation did not match.
Why the Session Was Not Rotated
Reading the authentication handler revealed a straightforward error. The session management code looked roughly like this:
python
def verify_login_link(token):
user = get_user_by_token(token)
if not user:
return redirect('/login?error=invalid')
# Mark token as used
invalidate_token(token)
# Set session
response = redirect('/dashboard')
response.set_cookie('session', token, httponly=True)
return responseThe invalidate_token call marked the token as used in the authentication token table — it could no longer be used to trigger a fresh login. But the same value was then placed into the session cookie and used for all subsequent authenticated requests.
The token was invalidated as an authentication credential but kept alive as a session credential. An attacker who had captured the token before it was "used" would find that their copy of the token no longer worked as a login link. But if they had already established a session with it — or if the session was bound to the known token value on first authentication — they remained logged in.
The correct approach:
python
def verify_login_link(token):
user = get_user_by_token(token)
if not user:
return redirect('/login?error=invalid')
# Invalidate the login token
invalidate_token(token)
# Generate a NEW session token — never reuse the login token
session_token = generate_secure_random_token()
create_session(session_token, user.id)
response = redirect('/dashboard')
response.set_cookie('session', session_token, httponly=True, secure=True, samesite='Strict')
return responseThe session token and the authentication token must be separate values. The login link serves one purpose: proving intent to authenticate. The session token serves a different purpose: maintaining an authenticated state. Using the same value for both conflates two different security properties into a single secret that must satisfy both.
Logout Did Not Help
Standard advice for session-related vulnerabilities often ends with: log out and the session is invalidated. Not here.
When the user logged out, the platform deleted the session cookie from the browser. The server-side session record was not invalidated. An attacker's copy of the session cookie — taken before or during the victim's session — continued to work after the victim logged out.
# Victim logs out
DELETE /auth/session HTTP/2
Cookie: session=a3f9c2d1e84b
→ 200 OK (cookie cleared from browser)
# Attacker still has the token
GET /api/account/profile HTTP/2
Cookie: session=a3f9c2d1e84b
→ 200 OK
Logout must invalidate the session server-side. Clearing the cookie is the client-side half of logout. The server must also delete or invalidate its record of the session. Without this, logout is theater.
The Scope of Exposure
The combination of issues — session fixation, token reuse, URL exposure, and client-only logout — created several independent attack paths:
- Predictable token: An attacker who could reverse-engineer or predict the token generation algorithm could forge login links without needing to intercept anything
- Intercepted token: An attacker with access to email infrastructure, shared inboxes, or the victim's forwarded mail could extract a token before it was used
- Log access: An attacker with access to server access logs — a common privilege for infrastructure teams — could extract session tokens for any user who had logged in recently
- Post-logout persistence: Even a session the victim believed was terminated remained usable
Any one of these would be a reportable vulnerability. All four present simultaneously on the same authentication flow represented a complete failure of session security.
The Broader Pattern
Session fixation is a well-known vulnerability class, documented in the OWASP Testing Guide and covered by CWE-384. It has been understood for over two decades. Yet it continues to appear in production systems, often in exactly this form — authentication mechanisms that generate credentials before the authentication act and fail to rotate them afterward.
Magic links, OAuth flows, SSO callbacks, and multi-step authentication wizards all share a common pattern: they create some form of pre-authentication token that transitions into post-authentication state. Each of these flows is a potential fixation point. The question is whether the token issued before authentication is the same token used to maintain the authenticated session.
The answer should always be no.
Key Takeaways
Session fixation exploits a conceptual error: treating the credential that proves identity the same as the credential that maintains identity. These are different things. The authentication token proves that someone with access to the victim's email clicked a link at a specific time. The session token maintains the claim that the current browser is authorized to act as that user. They must not be the same value.
The fix for this class of vulnerability is a single rule: always generate a new session identifier after successful authentication, regardless of what credentials were used to authenticate. Discard any pre-authentication state. Issue fresh, random, server-validated session tokens. Invalidate sessions on the server when users log out.
These are not new ideas. They are foundational. They appear in every session management framework, every security standard, every secure coding guide. The vulnerabilities persist not because the defenses are unknown, but because the authentication code was written without them in mind.
A login link is an authentication mechanism. A session cookie is a session mechanism. Build them separately.
Concerned about your authentication flow? Get in touch.