5 Web Auth Vulnerabilities
Session, cookie, and redirect attacks โ the classic web-layer auth failures. The practical companion to /auth-lab: here you learn how they break, there you practise them against live forms.
1. Cross-Site Request Forgery (CSRF)
OWASP A01:2021 โ Broken Access Control
Vulnerability: A state-changing endpoint (POST /account/delete, PATCH /email) authenticates via session cookies alone. An attacker's site submits a hidden form against your origin โ the browser attaches cookies automatically, and the action executes without consent.
Detection: Inspect the request in DevTools. If there's no anti-CSRF token or the server doesn't validate SameSite / Origin / Referer headers, CSRF is exploitable. Confirm by crafting a minimal external HTML form that fires POST against your endpoint.
# Victim browser hits attacker.example, which returns this HTML:
cat <<'HTML' > attack.html
<form action="https://bank.example/transfer" method="POST">
<input name="to" value="attacker-account-id">
<input name="amount" value="10000">
</form>
<script>document.forms[0].submit()</script>
HTML
# Victim's browser sends the form with their bank session cookie attached.
# Server MUST reject (403) based on Origin/Referer or missing CSRF token.test('state-changing POST rejects cross-origin request', async ({ page, request }) => {
await loginAs(page, 'victim@test.dev');
const cookies = await page.context().cookies();
const cookieHeader = cookies.map((c) => `${c.name}=${c.value}`).join('; ');
// Simulate cross-origin submission by overriding Origin
const res = await request.post('https://app.example/account/delete', {
headers: {
Cookie: cookieHeader,
Origin: 'https://evil.example',
Referer: 'https://evil.example/attack.html',
},
});
expect(res.status(), 'Server should reject cross-origin state change').toBe(403);
});// Network tab โ inspect a state-changing request:
// 1. Look for X-CSRF-Token or _csrf in the body
// 2. Check Set-Cookie for SameSite=Lax or Strict
// 3. Verify the server rejects the request if Origin header is removed
//
// Quick console probe:
await fetch('/account/delete', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
// Expect: 403 Forbidden if CSRF protection is in place.Mitigation: Set cookies with SameSite=Lax (or Strict). Require a rotating anti-CSRF token on every state-changing request. Validate the Origin header against an allow-list. Never accept GET for operations that mutate state.
2. Session fixation
OWASP A07:2021 โ Identification and Authentication Failures
Vulnerability: The server hands out a session ID on first visit and keeps the same ID after login. An attacker gets a session ID, tricks the victim into authenticating with it, and now owns their session.
Detection: Capture the Session ID cookie as an anonymous visitor. Log in. If the cookie value is unchanged, the session was not rotated โ fixation is exploitable.
# Pre-login: grab a session cookie
SID_BEFORE=$(curl -sI https://app.example/ | grep -i 'set-cookie: sid=' | sed 's/.*sid=\([^;]*\).*/\1/')
# Log in using that same cookie
curl -X POST https://app.example/login \
-H "Cookie: sid=$SID_BEFORE" \
-d 'email=victim@test.dev&password=correct'
# Post-login: fetch again, compare
SID_AFTER=$(curl -sI https://app.example/ -H "Cookie: sid=$SID_BEFORE" | grep -i 'set-cookie: sid=')
# If SID_AFTER is empty (no rotation), you have session fixation
echo "Before: $SID_BEFORE"
echo "After: $SID_AFTER"test('session ID rotates on login', async ({ browser }) => {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('/');
const beforeLogin = await context.cookies();
const sidBefore = beforeLogin.find((c) => c.name === 'sid')?.value;
await login(page, 'user@test.dev', 'correct');
const afterLogin = await context.cookies();
const sidAfter = afterLogin.find((c) => c.name === 'sid')?.value;
expect(sidAfter, 'Session ID must rotate on auth state change').not.toBe(sidBefore);
expect(sidAfter).toBeTruthy();
});// Application tab โ Cookies โ sid
// 1. Record the pre-login value
// 2. Log in
// 3. Record the post-login value
//
// If they match, the server isn't regenerating sessions.
// Console probe:
const before = document.cookie.match(/sid=([^;]+)/)?.[1];
// (log in manually through the UI)
const after = document.cookie.match(/sid=([^;]+)/)?.[1];
console.log({ before, after, rotated: before !== after });Mitigation: Regenerate the session ID on every authentication state change (login, logout, privilege escalation). Reject pre-login session IDs after auth. Bind sessions to user-agent / IP where UX allows.
3. Open redirect
OWASP A01:2021 โ Broken Access Control
Vulnerability: POST /login supports a ?next=... query that controls the post-login redirect. The server accepts any URL, including external ones. Attacker sends phishing links โ user logs in on the real site, then lands on attacker.example.
Detection: Pass an external URL as the redirect parameter. If the server issues a 302 to that URL (or client-side JS sets location), the redirect is open.
# Probe: does ?next=... accept external hosts?
curl -i "https://app.example/login?next=https://evil.example/phish"
# Expected: 400 / 302 to /login (reject external)
# Vulnerable: 302 Location: https://evil.example/phish
#
# Also probe tricky encodings:
curl -i "https://app.example/login?next=//evil.example/phish" # protocol-relative
curl -i "https://app.example/login?next=/\\evil.example" # backslash smuggle
curl -i "https://app.example/login?next=https:%2F%2Fevil.example" # URL-encodedtest('login redirect validates target URL', async ({ page }) => {
// External URL must be refused
await page.goto('/login?next=https://evil.example/phish');
await login(page, 'user@test.dev', 'correct');
await expect(page).toHaveURL(/^https:\/\/app\.example\//);
await expect(page).not.toHaveURL(/evil\.example/);
// Protocol-relative smuggle
await page.goto('/login?next=//evil.example');
await login(page, 'user@test.dev', 'correct');
await expect(page).toHaveURL(/^https:\/\/app\.example\//);
});// Network tab, follow the 302 after login:
// 1. Append ?next=https://example.com to the login URL
// 2. Log in and watch the redirect chain
// 3. If Location points off-origin, this is open-redirect
//
// Console probe (without actually logging in):
const url = new URL('https://app.example/login');
url.searchParams.set('next', 'https://evil.example');
console.log('Attack URL to share with victim:', url.toString());Mitigation: Accept only same-origin paths (must start with '/') AND reject '//' (protocol-relative). Prefer an internal lookup table: ?next=cart resolves server-side to /cart. Never trust user-controlled URLs for redirects.
4. Token leak via Referer header
OWASP A04:2021 โ Insecure Design
Vulnerability: A password-reset or OAuth callback URL contains the token in the query string. The reset page loads external resources (analytics, fonts, images). The browser's Referer header leaks the full URL โ including the token โ to those third parties.
Detection: Open the reset-password page in DevTools โ Network. Any outbound request that includes your app's URL (with the token) in Referer is a leak.
# Simulate browser behaviour manually
# 1. Victim clicks https://app.example/reset?token=ABC123
# 2. Page loads an external script:
curl -H "Referer: https://app.example/reset?token=ABC123" \
https://cdn.analytics.example/track.js
# Analytics CDN now sees the token in its access logs.
# Fix: Referrer-Policy: no-referrer on the reset page
curl -I https://app.example/reset?token=ABC123 | grep -i referrer-policytest('auth-sensitive pages use strict Referrer-Policy', async ({ page, request }) => {
const res = await request.get('/reset-password?token=ABC123');
const policy = res.headers()['referrer-policy'];
expect(policy, 'Reset page must restrict referrer').toMatch(
/^(no-referrer|same-origin|strict-origin)$/,
);
// Confirm outbound requests drop the referer
const outbound: string[] = [];
page.on('request', (req) => {
if (!req.url().startsWith('https://app.example')) {
outbound.push(req.headers().referer ?? '');
}
});
await page.goto('/reset-password?token=ABC123');
outbound.forEach((ref) => {
expect(ref, 'Third party must not receive token-bearing URL').not.toContain('token=');
});
});// Network tab, on the reset-password page:
// Filter by "third-party" origin.
// For each third-party request, inspect the Referer header.
// A full URL with ?token=... in Referer = leak.
//
// Also check the response of the reset page:
// Look for Referrer-Policy header. Missing or "unsafe-url" = broken.Mitigation: Set Referrer-Policy: no-referrer (or strict-origin-when-cross-origin) on pages that carry tokens in the URL. Prefer POST-based or cookie-based token delivery. Expire tokens aggressively (5-15 minutes). Consider double-submit cookie pattern.
5. Credential stuffing โ no rate limit on login
OWASP A07:2021 โ Identification and Authentication Failures
Vulnerability: POST /login accepts unlimited attempts. Attacker runs stolen credential pairs from breach dumps against the endpoint. Even 1 % success rate ร 10 million pairs = 100k compromised accounts.
Detection: Send 1000 login attempts in under a minute with varying emails/passwords. If none trigger a 429, lockout, or captcha challenge, credential stuffing is trivial.
# Probe: how many attempts before 429?
for i in $(seq 1 200); do
code=$(curl -s -o /dev/null -w '%{http_code}' https://app.example/login \
-H 'Content-Type: application/json' \
-d "{\"email\":\"probe$i@test.dev\",\"password\":\"x\"}")
echo "Attempt $i โ $code"
done | sort | uniq -c
# Vulnerable: all 200 attempts return 200/401 with no 429s
# Good: 429s appear within the first 10-20 attemptstest('login endpoint rate-limits brute force', async ({ request }) => {
const attempts = 100;
const results = await Promise.all(
Array.from({ length: attempts }, (_, i) =>
request.post('/api/login', {
data: { email: `probe${i}@test.dev`, password: 'wrong' },
}),
),
);
const blocked = results.filter((r) => r.status() === 429).length;
expect(blocked, 'Expected most attempts to be rate-limited').toBeGreaterThan(attempts * 0.5);
});// Console script โ never run against systems you don't own.
const probe = async (n = 50) => {
const codes: number[] = [];
for (let i = 0; i < n; i++) {
const r = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: `p${i}@x.io`, password: 'x' }),
});
codes.push(r.status);
}
console.table(codes.reduce((acc, c) => ({ ...acc, [c]: (acc[c] ?? 0) + 1 }), {}));
};
probe();
// Ideal output: { 401: 5, 429: 45 }
// Broken: { 401: 50, 429: 0 }Mitigation: Rate limit on (resolved IP + account email) โ each separately and combined. Add exponential backoff after 3-5 failures. Introduce captcha on threshold. Alert on distributed attempts (many IPs, single email โ credential stuffing; single IP, many emails โ enumeration). Reset on successful login.