Skip to content
Unverified — AI-generated content. Help verify this page

Auth Attacks & Defenses

Every authentication system is under constant attack. Understanding the attack landscape is not optional — it is a prerequisite for building defensible systems. This page catalogs every major auth attack vector with detection techniques, defense implementations, and real-world incident examples. Treat this as your threat model reference for authentication systems.

Attack Taxonomy

Credential Stuffing

The Attack

Attackers use credentials leaked from one breach to attempt login on other services. Because 65%+ of users reuse passwords, a significant percentage of attempts succeed.

Detection Signals

SignalDescriptionDetection Method
High failure rate from IP>10 failures per minutePer-IP counter with Redis
Many usernames from one IPDifferent emails, same IPTrack unique emails per IP
Known credential pairEmail+password in breach DBCheck against HaveIBeenPwned API
Non-human timingRequests at perfectly regular intervalsJitter analysis
Missing browser signalsNo JavaScript execution, no cookiesChallenge with JavaScript proof-of-work
Residential proxy IPsAttacker rotates through residential proxiesIP reputation scoring

Defense Implementation

typescript
import { createHash } from 'crypto';

// Defense 1: Breached password check (k-anonymity model)
async function isPasswordBreached(password: string): Promise<boolean> {
  const hash = createHash('sha1').update(password).digest('hex').toUpperCase();
  const prefix = hash.slice(0, 5);
  const suffix = hash.slice(5);

  // k-anonymity: only send first 5 chars of hash
  const response = await fetch(
    `https://api.pwnedpasswords.com/range/${prefix}`,
    { headers: { 'Add-Padding': 'true' } } // Prevents response size analysis
  );

  const text = await response.text();
  const lines = text.split('\n');

  for (const line of lines) {
    const [hashSuffix, count] = line.split(':');
    if (hashSuffix.trim() === suffix) {
      return true; // Password has been breached
    }
  }

  return false;
}

// Defense 2: Progressive delays
const DELAY_MAP: Record<number, number> = {
  1: 0,       // No delay for first attempt
  2: 0,       // No delay
  3: 1000,    // 1 second
  4: 2000,    // 2 seconds
  5: 5000,    // 5 seconds
  6: 10000,   // 10 seconds
  7: 30000,   // 30 seconds
};

async function getLoginDelay(identifier: string): Promise<number> {
  const attempts = await redis.get(`login_attempts:${identifier}`);
  const count = parseInt(attempts || '0', 10);
  return DELAY_MAP[Math.min(count, 7)] || 60000; // Max 60s
}

// Defense 3: Device-based rate limiting
// Even if attacker rotates IPs, the device fingerprint stays the same
async function checkDeviceRateLimit(
  deviceFingerprint: string
): Promise<boolean> {
  const key = `device_login:${deviceFingerprint}`;
  const count = await redis.incr(key);
  if (count === 1) await redis.expire(key, 3600); // 1 hour window
  return count <= 20; // Max 20 login attempts per device per hour
}

Do Not Reveal Valid Usernames

Always return the same response for invalid username and invalid password: "Invalid email or password". If you return "User not found" vs "Wrong password", attackers can enumerate valid accounts first, then focus credential stuffing on confirmed accounts.

Brute Force

Defense: Progressive Lockout

typescript
interface LockoutState {
  attempts: number;
  lockedUntil: Date | null;
  lastAttempt: Date;
}

async function handleFailedLogin(
  userId: string,
  ip: string
): Promise<{ locked: boolean; lockDuration?: number }> {
  const key = `lockout:${userId}`;
  const state = await redis.hgetall(key);
  const attempts = parseInt(state.attempts || '0') + 1;

  // Update attempt count
  await redis.hset(key, {
    attempts: attempts.toString(),
    lastAttempt: Date.now().toString(),
  });
  await redis.expire(key, 86400); // Reset after 24 hours

  // Progressive lockout
  if (attempts >= 10) {
    const lockDuration = 3600; // 1 hour
    await redis.hset(key, 'lockedUntil',
      (Date.now() + lockDuration * 1000).toString());
    await notifyUser(userId, 'account_locked', { attempts, lockDuration });
    return { locked: true, lockDuration };
  }

  if (attempts >= 5) {
    const lockDuration = 300; // 5 minutes
    await redis.hset(key, 'lockedUntil',
      (Date.now() + lockDuration * 1000).toString());
    return { locked: true, lockDuration };
  }

  if (attempts >= 3) {
    // Require CAPTCHA but do not lock
    return { locked: false };
  }

  return { locked: false };
}

// Check if account is locked before processing login
async function isAccountLocked(userId: string): Promise<boolean> {
  const lockedUntil = await redis.hget(`lockout:${userId}`, 'lockedUntil');
  if (!lockedUntil) return false;
  return Date.now() < parseInt(lockedUntil);
}

// Reset on successful login
async function handleSuccessfulLogin(userId: string): Promise<void> {
  await redis.del(`lockout:${userId}`);
}

Account Lockout Denial-of-Service

If you lock accounts after N failures, an attacker can intentionally lock out any user by sending N bad attempts. Mitigate by:

  1. Locking by IP+account pair, not just account
  2. Allowing CAPTCHA bypass instead of hard lockout
  3. Sending unlock links via email
  4. Using progressive delays instead of full lockout

Session Hijacking

Attack Vectors

typescript
// Maximum cookie security configuration
const COOKIE_CONFIG = {
  httpOnly: true,       // Blocks document.cookie access (prevents XSS theft)
  secure: true,         // HTTPS only (prevents MITM sniffing)
  sameSite: 'lax',      // Blocks cross-site requests (prevents CSRF)
  path: '/',            // Required for __Host- prefix
  maxAge: 86400 * 1000, // 24 hours
  // No 'domain' attribute — required for __Host- prefix
  // Use __Host- prefix in cookie name
};

// Session binding — tie session to device characteristics
async function validateSessionBinding(
  session: SessionData,
  req: Request
): Promise<boolean> {
  // Binding 1: User-Agent (catches crude theft)
  if (session.userAgent !== req.headers['user-agent']) {
    await auditLog.warn('session_ua_mismatch', {
      sessionId: session.id,
      expected: session.userAgent,
      actual: req.headers['user-agent'],
    });
    return false;
  }

  // Binding 2: TLS session (if using mutual TLS)
  // Not applicable for most web apps but essential for API-to-API

  // Binding 3: DPoP token binding (RFC 9449)
  // If using DPoP, verify the proof matches the session's key thumbprint
  if (session.dpopThumbprint) {
    const dpopProof = req.headers['dpop'];
    if (!dpopProof || !verifyDPoPBinding(dpopProof, session.dpopThumbprint)) {
      return false;
    }
  }

  return true;
}

CSRF (Cross-Site Request Forgery)

The Attack

Defense Strategies

StrategyHow It WorksLimitations
SameSite=Lax cookiesBrowser does not send cookie on cross-site POSTSome old browsers do not support
SameSite=Strict cookiesBrowser never sends cookie cross-siteBreaks legitimate cross-site navigation
Synchronizer Token PatternServer-generated token in form + session; verify matchRequires server-side state
Double-Submit CookieRandom token in cookie + request header; server verifies matchDoes not require server state
Origin/Referer checkVerify Origin or Referer header matches expected domainHeaders can be stripped by proxies
Custom request headerRequire X-Requested-With or similarOnly works for AJAX, not form submissions
typescript
import { randomBytes, timingSafeEqual } from 'crypto';

// Generate CSRF token on session creation
function generateCSRFToken(): string {
  return randomBytes(32).toString('hex');
}

// Middleware: Set CSRF token cookie + validate on mutations
function csrfProtection(req: Request, res: Response, next: NextFunction): void {
  // Skip for safe methods
  if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
    // Ensure CSRF cookie exists
    if (!req.cookies['csrf-token']) {
      const token = generateCSRFToken();
      res.cookie('csrf-token', token, {
        httpOnly: false,  // JavaScript MUST read this
        secure: true,
        sameSite: 'strict',
        path: '/',
        maxAge: 86400 * 1000,
      });
    }
    return next();
  }

  // Mutation request — validate CSRF token
  const cookieToken = req.cookies['csrf-token'];
  const headerToken = req.headers['x-csrf-token'] as string;

  if (!cookieToken || !headerToken) {
    return res.status(403).json({ error: 'CSRF token missing' });
  }

  // Constant-time comparison
  const cookieBuffer = Buffer.from(cookieToken);
  const headerBuffer = Buffer.from(headerToken);

  if (cookieBuffer.length !== headerBuffer.length ||
      !timingSafeEqual(cookieBuffer, headerBuffer)) {
    await auditLog.warn('csrf_validation_failed', {
      ip: req.ip,
      path: req.path,
      origin: req.headers.origin,
    });
    return res.status(403).json({ error: 'CSRF token invalid' });
  }

  next();
}

SameSite=Lax Is Often Sufficient

If all your state-changing operations use POST/PUT/DELETE (not GET), and your cookies use SameSite=Lax, you have CSRF protection without any token mechanism. Lax blocks cross-site POST requests while allowing cross-site GET navigation (so links from emails still work). Add token-based CSRF protection as defense-in-depth for critical operations.

Token Theft (XSS to Token Exfiltration)

The Attack Chain

1. Attacker finds XSS vulnerability (stored or reflected)
2. Injected script runs in victim's browser context
3. Script reads token from:
   - localStorage (most common)
   - sessionStorage
   - document.cookie (if not HttpOnly)
   - JavaScript variables
4. Script sends token to attacker's server
5. Attacker uses token to impersonate victim

Secure Token Storage Patterns

StorageXSS Readable?CSRF Risk?PersistenceRecommendation
localStorageYesNoPermanentNever for auth tokens
sessionStorageYesNoTab onlyNever for auth tokens
document.cookieYes (if not HttpOnly)YesConfigurableOnly with HttpOnly
HttpOnly cookieNoYes (mitigate with SameSite)ConfigurableBest for web apps
Memory variableYes (but harder)NoPage lifetimeGood for SPAs (with refresh)
Service WorkerNo (isolated scope)NoWorker lifetimeExperimental

localStorage Is Never Safe for Tokens

Any XSS vulnerability anywhere in your application (including third-party scripts) can read localStorage. An attacker does not need XSS in your auth code — XSS in a marketing widget, analytics script, or chat plugin is sufficient. Use HttpOnly cookies for token storage, always.

SPA Token Storage Pattern

typescript
// In-memory token storage for SPAs
// Tokens exist only in JavaScript memory — XSS cannot easily extract them
// from closure scope, and they disappear on page refresh

class TokenStore {
  private accessToken: string | null = null;
  private refreshTimer: ReturnType<typeof setTimeout> | null = null;

  setTokens(access: string, expiresIn: number): void {
    this.accessToken = access;

    // Auto-refresh before expiry
    if (this.refreshTimer) clearTimeout(this.refreshTimer);
    this.refreshTimer = setTimeout(
      () => this.refreshAccessToken(),
      (expiresIn - 60) * 1000 // Refresh 60s before expiry
    );
  }

  getAccessToken(): string | null {
    return this.accessToken;
  }

  private async refreshAccessToken(): Promise<void> {
    // Refresh token is in HttpOnly cookie — sent automatically
    const response = await fetch('/auth/refresh', {
      method: 'POST',
      credentials: 'include', // Send HttpOnly cookie
    });

    if (response.ok) {
      const { accessToken, expiresIn } = await response.json();
      this.setTokens(accessToken, expiresIn);
    } else {
      // Refresh failed — redirect to login
      this.accessToken = null;
      window.location.href = '/login';
    }
  }

  clear(): void {
    this.accessToken = null;
    if (this.refreshTimer) clearTimeout(this.refreshTimer);
  }
}

OAuth Redirect Attacks

Open Redirect via redirect_uri

Defense

typescript
// Strict redirect URI validation — exact match only
function validateRedirectUri(
  requestedUri: string,
  registeredUris: string[]
): boolean {
  // EXACT match — no wildcards, no subpaths, no query params
  return registeredUris.includes(requestedUri);

  // WRONG approaches:
  // ❌ requestedUri.startsWith(registeredUri) — allows /callback/../../evil
  // ❌ new URL(requestedUri).hostname === allowedHost — allows subdomains
  // ❌ regex matching — complex and error-prone
}

state Parameter for CSRF Prevention

typescript
// Always use a cryptographic state parameter
async function initiateOAuthFlow(req: Request, res: Response): Promise<void> {
  const state = randomBytes(32).toString('hex');

  // Store state in session — verify on callback
  req.session.oauthState = state;
  req.session.oauthStateCreatedAt = Date.now();

  const authUrl = new URL('https://auth.example.com/authorize');
  authUrl.searchParams.set('response_type', 'code');
  authUrl.searchParams.set('client_id', CLIENT_ID);
  authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
  authUrl.searchParams.set('state', state);
  authUrl.searchParams.set('code_challenge', pkceChallenge);
  authUrl.searchParams.set('code_challenge_method', 'S256');

  res.redirect(authUrl.toString());
}

async function handleOAuthCallback(req: Request, res: Response): Promise<void> {
  const { code, state } = req.query;

  // Validate state
  if (!state || state !== req.session.oauthState) {
    return res.status(403).json({ error: 'Invalid state parameter — CSRF detected' });
  }

  // Check state age (prevent replay)
  const stateAge = Date.now() - (req.session.oauthStateCreatedAt || 0);
  if (stateAge > 600000) { // 10 minutes
    return res.status(403).json({ error: 'OAuth state expired' });
  }

  // Clear state (one-time use)
  delete req.session.oauthState;
  delete req.session.oauthStateCreatedAt;

  // Exchange code for tokens (with PKCE verifier)
  // ...
}

JWT Attacks

Attack 1: none Algorithm

The attacker modifies the JWT header to "alg": "none" and removes the signature. If the server does not validate the algorithm, it accepts unsigned tokens.

json
// Attacker-crafted token
{
  "header": { "alg": "none", "typ": "JWT" },
  "payload": { "sub": "admin", "role": "superadmin" },
  "signature": ""  // Empty — no signature
}

Defense: Always specify an explicit algorithm allowlist. Never accept none.

typescript
import { jwtVerify } from 'jose';

const { payload } = await jwtVerify(token, publicKey, {
  algorithms: ['ES256'],  // ONLY accept ES256
  // If someone sends alg: "none", alg: "HS256", etc., it is rejected
});

Attack 2: Key Confusion (RS256 → HS256)

If the server uses RS256 (asymmetric), the attacker changes the algorithm to HS256 (symmetric) and signs the token with the public key (which is publicly available). If the server uses the same key for both algorithms, it validates the HS256 signature using the public key as the HMAC secret.

Defense: Use algorithm allowlists and separate keys per algorithm.

typescript
// WRONG — vulnerable to key confusion
const decoded = jwt.verify(token, publicKey); // Accepts any algorithm

// CORRECT — explicit algorithm
const decoded = jwt.verify(token, publicKey, { algorithms: ['ES256'] });

Attack 3: JWK Injection (jwk Header)

The attacker embeds their own public key in the JWT jwk header parameter. If the server extracts the key from the header to verify the signature, the attacker controls the verification key.

json
{
  "header": {
    "alg": "RS256",
    "jwk": {
      "kty": "RSA",
      "n": "attacker_public_key...",
      "e": "AQAB"
    }
  }
}

Defense: Never extract the verification key from the token itself. Always use server-side key storage or a trusted JWKS endpoint.

typescript
// WRONG — trusting the token's embedded key
const key = token.header.jwk;
jwt.verify(token, key); // Attacker controls the key!

// CORRECT — use server-side JWKS
const JWKS = createRemoteJWKSet(
  new URL('https://auth.example.com/.well-known/jwks.json')
);
const { payload } = await jwtVerify(token, JWKS, {
  algorithms: ['ES256'],
  issuer: 'https://auth.example.com',
});

JWT Security Checklist

CheckStatusAttack Prevented
Algorithm allowlist (never accept none)Requirednone algorithm, key confusion
Verify exp claimRequiredExpired token replay
Verify iss claimRequiredToken from wrong issuer
Verify aud claimRequiredCross-service token reuse
Never extract key from token headerRequiredJWK injection
Use kid for key lookup from trusted storeRequiredKey confusion
Verify nbf (not before) if presentRecommendedPremature token use
Check token blocklistRecommendedRevoked token use
Validate claims types (string, number, etc.)RecommendedType confusion

Privilege Escalation

IDOR (Insecure Direct Object Reference)

The attacker modifies an object ID in the request to access another user's resources.

GET /api/users/123/invoices     → User 123's invoices (legitimate)
GET /api/users/456/invoices     → User 456's invoices (unauthorized!)
GET /api/users/1/invoices       → Admin's invoices (critical!)

Defense:

typescript
// WRONG — trusts the URL parameter
app.get('/api/users/:userId/invoices', async (req, res) => {
  const invoices = await db.invoices.findByUserId(req.params.userId);
  res.json(invoices);
});

// CORRECT — enforces ownership
app.get('/api/users/:userId/invoices', async (req, res) => {
  // The authenticated user can only access their own resources
  if (req.params.userId !== req.user.id && !req.user.roles.includes('admin')) {
    return res.status(403).json({ error: 'Forbidden' });
  }
  const invoices = await db.invoices.findByUserId(req.params.userId);
  res.json(invoices);
});

// BETTER — do not use user ID in URL at all
app.get('/api/me/invoices', async (req, res) => {
  // Always use the authenticated user's ID from the session/token
  const invoices = await db.invoices.findByUserId(req.user.id);
  res.json(invoices);
});

Broken Function-Level Authorization

The attacker accesses admin endpoints that lack authorization checks.

POST /api/admin/users/delete     → No auth check → anyone can delete users
PUT  /api/admin/config           → No auth check → anyone can modify config

Defense: Authorization middleware on every route, deny by default.

typescript
// Deny by default — every route must explicitly declare required roles
function requireRole(...roles: string[]) {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.user) {
      return res.status(401).json({ error: 'Not authenticated' });
    }

    const hasRole = roles.some(role => req.user!.roles.includes(role));
    if (!hasRole) {
      auditLog.warn('authorization_denied', {
        userId: req.user.id,
        requiredRoles: roles,
        userRoles: req.user.roles,
        path: req.path,
      });
      return res.status(403).json({ error: 'Insufficient permissions' });
    }

    next();
  };
}

// Apply to routes
app.delete('/api/admin/users/:id', requireRole('admin'), deleteUser);
app.put('/api/admin/config', requireRole('superadmin'), updateConfig);

Bot Detection

CAPTCHA and Alternatives

SolutionUser FrictionBot Detection RatePrivacyCost
reCAPTCHA v3None (invisible)~95%Low (Google tracking)Free up to 1M
reCAPTCHA v2Medium (click checkbox)~98%LowFree up to 1M
hCaptchaMedium~95%Better than reCAPTCHAFree tier available
Turnstile (Cloudflare)None (invisible)~96%High (no tracking)Free
Proof of workNone (CPU cost)~90%Perfect (no third party)Free
Behavioral analysisNone~85%HighMedium

Proof of Work (No Third Party)

Instead of a CAPTCHA, require the client to solve a computational puzzle. This makes automated attacks expensive without affecting legitimate users.

typescript
// Server: Issue challenge
app.post('/auth/challenge', (req, res) => {
  const challenge = randomBytes(32).toString('hex');
  const difficulty = 4; // Number of leading zeros required in hash

  // Store challenge (prevents reuse)
  redis.setex(`pow:${challenge}`, 300, '1'); // 5 min expiry

  res.json({ challenge, difficulty });
});

// Server: Verify proof of work
app.post('/auth/login', async (req, res) => {
  const { email, password, challenge, nonce } = req.body;

  // Verify proof of work
  const exists = await redis.get(`pow:${challenge}`);
  if (!exists) {
    return res.status(400).json({ error: 'Invalid or expired challenge' });
  }

  const hash = createHash('sha256')
    .update(challenge + nonce)
    .digest('hex');

  const difficulty = 4;
  if (!hash.startsWith('0'.repeat(difficulty))) {
    return res.status(400).json({ error: 'Invalid proof of work' });
  }

  // Consume challenge (prevent reuse)
  await redis.del(`pow:${challenge}`);

  // Process login...
});
typescript
// Client: Solve proof of work
async function solveProofOfWork(
  challenge: string,
  difficulty: number
): Promise<string> {
  const target = '0'.repeat(difficulty);
  let nonce = 0;

  while (true) {
    const hash = await crypto.subtle.digest(
      'SHA-256',
      new TextEncoder().encode(challenge + nonce)
    );
    const hexHash = Array.from(new Uint8Array(hash))
      .map(b => b.toString(16).padStart(2, '0'))
      .join('');

    if (hexHash.startsWith(target)) {
      return nonce.toString();
    }
    nonce++;

    // Yield to UI thread every 10,000 iterations
    if (nonce % 10000 === 0) {
      await new Promise(resolve => setTimeout(resolve, 0));
    }
  }
}

Defense-in-Depth Summary

Further Reading

"What I cannot create, I do not understand." — Richard Feynman