Skip to main content
Back to Auth Lab
Auth Security Lab

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

high

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.

curl
# 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.
Playwright
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);
});
DevTools / Console
// 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

critical

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.

curl
# 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"
Playwright
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();
});
DevTools / Console
// 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

medium

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.

curl
# 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-encoded
Playwright
test('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\//);
});
DevTools / Console
// 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

high

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.

curl
# 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-policy
Playwright
test('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=');
  });
});
DevTools / Console
// 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

critical

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.

curl
# 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 attempts
Playwright
test('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);
});
DevTools / Console
// 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.