← Blog
·11 min read

OWASP A07 Authentication Failures Guide

OWASP A07 authentication failures with real code examples: weak passwords, JWT without expiry, localStorage tokens, no rate limiting, and how to fix each.

Rod

Founder & Developer

OWASP authentication failures (A07:2021) dropped from the #2 spot in 2017 to #7 in 2021. That sounds like progress, and in a way it is — modern frameworks ship better auth defaults than they did five years ago. But the drop happened because the category got narrower, not because the bugs disappeared. JWT tokens without expiration, login endpoints with no rate limiting, tokens sitting in localStorage, OAuth flows missing the state parameter — we still see these in real repos every week.

This guide covers every common failure mode in A07, with before/after code examples and actionable fixes. No vague advice. Just the specific things to check and change.


Why Authentication Failures Still Rank in the OWASP Top 10

The 2021 reorganization merged the old "Broken Authentication" category with "Broken Session Management" and renamed it "Identification and Authentication Failures." OWASP cited improved framework defaults as the main reason for the ranking drop.

Here's the problem: frameworks give you the tools to implement auth correctly. They can't make you use them correctly.

The Uber breach in 2022 started with credential stuffing — an attacker used leaked credentials to access an Uber contractor's account. Then they used MFA fatigue (repeatedly pushing MFA notifications until the contractor accepted one) to get in. No exotic zero-day. Just persistence against an auth flow that lacked account lockout after repeated MFA failures.

The Okta breach in 2022 involved compromised support system credentials with excessive session lifetimes. A support account had sessions that stayed valid for weeks without re-authentication.

These aren't theoretical vulnerabilities. They're specific, fixable implementation gaps.


The 8 Authentication Failures You Need to Check

1. Tokens Stored in localStorage

This is the one that trips up the most developers who are new to auth. It feels natural — localStorage is simple, persistent, and easy to access from JavaScript. That's exactly why it's dangerous.

// BAD: Any script on your page can read this
localStorage.setItem('token', jwtToken);
 
// Later...
const token = localStorage.getItem('token');
fetch('/api/data', {
  headers: { Authorization: `Bearer ${token}` }
});

Any XSS vulnerability anywhere on your site — in a third-party script, in user-generated content, in an ad — can steal this token. The attacker doesn't need access to your servers. They just need one line of injected JavaScript to run in a user's browser.

// GOOD: httpOnly cookie — JavaScript can't read it at all
// Set this on the server when the user logs in
res.setHeader('Set-Cookie', [
  `token=${jwtToken}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=3600`
]);
 
// The browser sends it automatically — no JS needed
fetch('/api/data'); // cookie included automatically

The HttpOnly flag tells the browser: "Never let JavaScript access this cookie." The Secure flag ensures it only goes over HTTPS. SameSite=Strict prevents cross-site request forgery. Together, they make the token inaccessible to injected scripts.

Learn more about the specific vulnerability in our tokens in localStorage security reference.

2. JWT Without Expiration

A JWT without an exp claim is a permanent credential. Lose it once and it's valid until your server restarts, your signing key rotates, or you implement a blocklist (which defeats much of the point of JWTs).

// BAD: No exp claim — this token is valid forever
const token = jwt.sign(
  { userId: user.id, role: user.role },
  process.env.JWT_SECRET
  // No expiresIn option = no exp claim
);
// GOOD: Short-lived access token + refresh token pattern
const accessToken = jwt.sign(
  { userId: user.id, role: user.role },
  process.env.JWT_SECRET,
  { expiresIn: '15m' } // expires in 15 minutes
);
 
const refreshToken = jwt.sign(
  { userId: user.id },
  process.env.REFRESH_TOKEN_SECRET,
  { expiresIn: '7d' } // longer lived, stored in httpOnly cookie
);
 
// Send access token in response body or a short-lived cookie
// Send refresh token as httpOnly cookie

The pattern: access tokens expire in 15 minutes to 1 hour. Refresh tokens last longer (7–30 days) but are stored in httpOnly cookies and rotated on each use. If an access token is stolen, it's valid for at most 15 minutes. If a refresh token is stolen, you detect it through rotation (the old token is invalid after use, so a reuse attempt signals theft).

See the full vulnerability reference for JWT without expiration.

3. Cookies Without the HttpOnly Flag

Even if you're not using localStorage, cookie misconfiguration is its own failure mode. An httpOnly: false cookie is readable by JavaScript — same XSS exposure, different storage mechanism.

// BAD: Session cookie readable by any JavaScript on the page
res.cookie('session', sessionToken, {
  secure: true,
  // httpOnly is false by default in most libraries
});
// GOOD: HttpOnly + Secure + SameSite = full protection
res.cookie('session', sessionToken, {
  httpOnly: true,   // no JS access
  secure: true,     // HTTPS only
  sameSite: 'strict', // no cross-site sending
  maxAge: 3600000   // 1 hour in milliseconds
});

Check your cookies without httpOnly flag configuration right now. It's a one-line fix.

4. No Rate Limiting on the Login Endpoint

An unprotected /login endpoint accepts unlimited password guesses. This enables two attack types: brute force (trying passwords systematically) and credential stuffing (trying leaked username/password pairs from other breaches).

// BAD: No rate limiting — attacker can try 10,000 passwords/minute
export async function POST(req: Request) {
  const { email, password } = await req.json();
  const user = await verifyCredentials(email, password);
  // ...
}
// GOOD: Rate limit by IP before processing the request
import { rateLimit } from '@/lib/rate-limit';
 
export async function POST(req: Request) {
  const ip = req.headers.get('x-forwarded-for') ?? 'unknown';
 
  // 5 attempts per minute per IP
  const { success } = await rateLimit(ip, { limit: 5, window: 60 });
  if (!success) {
    return Response.json(
      { error: 'Too many login attempts. Try again later.' },
      { status: 429 }
    );
  }
 
  const { email, password } = await req.json();
  const user = await verifyCredentials(email, password);
  // ...
}

Layer your protections: IP-based rate limiting at the edge (before the request hits your server), account lockout after N failed attempts, and exponential backoff that increases wait time with each failure. See no account lockout for the full implementation pattern.

5. User Enumeration Through Error Messages

This one is subtle. Your app probably has different error messages for "email doesn't exist" and "wrong password." That's helpful for users. It's also a roadmap for attackers.

// BAD: Different messages reveal whether the email is registered
if (!user) {
  return Response.json({ error: 'Email not found' }, { status: 401 });
}
if (!passwordMatches) {
  return Response.json({ error: 'Wrong password' }, { status: 401 });
}

An attacker can automate requests with a list of email addresses. Any response with "Email not found" tells them that account doesn't exist. "Wrong password" tells them it does. Now they have a verified list of registered accounts — perfect for a credential stuffing campaign.

// GOOD: Same message regardless of which check failed
if (!user || !passwordMatches) {
  return Response.json(
    { error: 'Invalid credentials' }, // same message always
    { status: 401 }
  );
}

Timing attacks are the other side of this. If checking a nonexistent user returns in 1ms (no password hash comparison) but a wrong password returns in 100ms (bcrypt compare), the response time reveals the same information as different error messages. Use a constant-time dummy hash comparison when the user doesn't exist.

Learn more about user enumeration vulnerabilities and how to prevent timing-based leaks.

6. Missing OAuth State Parameter

If your app uses OAuth (GitHub login, Google login, etc.) without the state parameter, you're vulnerable to CSRF attacks on the OAuth flow. An attacker can craft a link that, when clicked, completes an OAuth login that binds the attacker's external account to the victim's existing session.

// BAD: OAuth redirect with no state parameter
const authUrl = `https://github.com/login/oauth/authorize?
  client_id=${CLIENT_ID}&
  redirect_uri=${REDIRECT_URI}&
  scope=read:user`;
// No state = no CSRF protection on the callback
// GOOD: Generate random state, store in session, verify on callback
import { randomBytes } from 'crypto';
 
// When initiating OAuth
const state = randomBytes(32).toString('hex');
// Store in server-side session or signed cookie
req.session.oauthState = state;
 
const authUrl = `https://github.com/login/oauth/authorize?
  client_id=${CLIENT_ID}&
  redirect_uri=${REDIRECT_URI}&
  scope=read:user&
  state=${state}`;
 
// On callback — verify state matches before exchanging code
export async function GET(req: Request) {
  const { code, state } = Object.fromEntries(new URL(req.url).searchParams);
 
  if (state !== req.session.oauthState) {
    return Response.json({ error: 'Invalid state parameter' }, { status: 400 });
  }
  // Safe to exchange code for token
}

See the full OAuth missing state parameter reference for all the edge cases.

7. No Session Invalidation on Password Change or Logout

When a user changes their password, all existing sessions should be invalidated immediately. If they don't, an attacker who stole a session token continues to have access even after the user "secured" their account.

The same applies to logout. A "logout" that only clears the client-side token but leaves the server-side session alive does nothing against a stolen token.

// BAD: Password change doesn't invalidate other sessions
async function changePassword(userId: string, newPassword: string) {
  const hashed = await bcrypt.hash(newPassword, 12);
  await db.users.update({ where: { id: userId }, data: { password: hashed } });
  // Existing sessions still valid — attacker keeps access
}
// GOOD: Rotate session secret or invalidate all sessions on password change
async function changePassword(userId: string, newPassword: string) {
  const hashed = await bcrypt.hash(newPassword, 12);
 
  await db.$transaction([
    // Update password
    db.users.update({ where: { id: userId }, data: { password: hashed } }),
    // Delete all existing sessions for this user
    db.sessions.deleteMany({ where: { userId } }),
  ]);
}

With JWTs (stateless), the approach is different: include a passwordChangedAt timestamp in the token validation logic. Reject any token issued before the last password change.

8. Weak Password Policy

NIST's Digital Identity Guidelines (SP 800-63B) updated the standard in 2017. The old advice (special characters, forced rotation, complexity rules) actually makes passwords worse by making them predictable and encouraging reuse. The new standard: minimum 8 characters (NIST recommends 15+), no forced rotation, check against known-breached password lists.

// BAD: Arbitrary complexity rules that don't improve security
function isValidPassword(password: string): boolean {
  return (
    password.length >= 8 &&
    /[A-Z]/.test(password) && // uppercase required
    /[0-9]/.test(password) && // number required
    /[!@#$]/.test(password)   // special character required
  );
  // Result: users pick "Password1!" — meets rules, trivially guessable
}
// GOOD: Length-based policy + breached password check
import { checkHIBP } from '@/lib/hibp'; // Have I Been Pwned API
 
async function isValidPassword(password: string): Promise<boolean> {
  if (password.length < 12) return false;
 
  // Check against known breached passwords
  const isBreached = await checkHIBP(password);
  if (isBreached) return false;
 
  return true;
}

The Have I Been Pwned Passwords API uses k-anonymity — you send the first 5 characters of the SHA-1 hash, get back matching hashes, and check locally. The actual password never leaves your server. See the weak password policy reference for a full implementation.


A Real Breach: Uber 2022 and MFA Fatigue

In September 2022, an attacker compromised Uber's internal systems using a technique called MFA fatigue. Here's what happened:

  1. The attacker obtained the Uber contractor's credentials — likely through a data broker or prior breach.
  2. They attempted to log in repeatedly. MFA was enabled, so each attempt pushed a notification to the contractor's phone.
  3. After dozens of push notifications, the attacker sent a WhatsApp message to the contractor impersonating IT support: "You're getting these because we need to verify your account. Please accept."
  4. The contractor accepted. The attacker was in.

No code vulnerability exploited. Just an MFA implementation that allowed unlimited push notifications without lockout.

The fix isn't complex: limit MFA push notifications to 3–5 per hour per account. After that threshold, require a different factor (TOTP code, email verification) and flag the account for review. Number-matching in MFA apps (showing the same number in the app and the login page) also defeats this — the user has to actively confirm they initiated the request.

There's no no MFA/2FA situation that's easier to defend than one where you simply implement basic MFA rate limiting.


Prevention: Use Battle-Tested Auth, Not DIY

The hardest part about authentication security isn't knowing the rules. It's implementing all of them correctly, all the time, in every new project. Most auth bugs are bugs of omission — someone forgot the state parameter, or didn't add expiration to the JWT, or never got around to the rate limiter.

The pragmatic answer: don't build auth from scratch.

Supabase Auth handles password hashing, email confirmation flows, OAuth state parameters, session management, and token rotation. Their auth documentation covers all of this. If you're already on Supabase, you have solid auth defaults available — you just need to configure them correctly (short session lifetimes, MFA enabled, email confirmation required).

NextAuth.js / Auth.js is the standard for Next.js apps. It handles OAuth state parameters, CSRF protection, and session management by default. The httpOnly cookie storage is the default. You'd have to work against the defaults to store tokens insecurely.

Auth0 and similar managed services offload the entire auth surface to specialists. Worth the cost if you're handling sensitive data and don't want to maintain the auth layer yourself.

Using a library doesn't make you immune. You still need to configure short token lifetimes, enable MFA, add rate limiting on your own endpoints, and check that your OAuth integrations use the state parameter. Libraries give you the foundation — you still have to build on it correctly.

For a complete list of what to verify regardless of which library you use, see the authentication failures reference in our security encyclopedia.


The Checklist: What to Audit Right Now

Run through these against your current project:

Check Risk if missing Fix
Tokens in localStorage XSS token theft Move to httpOnly cookie
JWT without exp claim Stolen tokens valid forever Add expiresIn: '15m'
No rate limit on /login Brute force / credential stuffing 5 req/min per IP, account lockout
User enumeration in error messages Account harvesting Single "Invalid credentials" message
OAuth without state parameter CSRF on OAuth flow Generate and verify random state
Sessions survive password change Attacker keeps access post-compromise Invalidate all sessions on password change
Weak password policy Easily guessed credentials 12+ chars + HIBP check
No MFA Single point of failure TOTP or push with rate limiting
Cookies without HttpOnly JavaScript can read session token Set httpOnly: true
Insecure password reset flow Account takeover via reset link Single-use tokens, short expiry, rate limited

Check Your App for Authentication Vulnerabilities

We scan for authentication failures, JWT without expiration, tokens in localStorage, cookies without HttpOnly, no account lockout, weak password policy, OAuth missing state parameter, user enumeration, insecure password reset, and no MFA/2FA — along with 180+ other checks — in every repository scan.

When we scanned a sample of 50 production Next.js repositories, 34 of them had at least one auth-related finding. The most common: JWT tokens without expiration (22 repos), followed by no rate limiting on login endpoints (18 repos), and tokens stored in localStorage (11 repos).

Scan your repo free and see your auth findings →

The scan connects via GitHub OAuth (read-only), runs in about 60 seconds, and gives you a prioritized list of findings with specific file locations and suggested fixes. No credit card required for the first 3 scans.


Frequently Asked Questions

What is OWASP A07:2021 Identification and Authentication Failures?

OWASP A07:2021 covers weaknesses in how applications verify user identity and manage sessions. This includes weak password policies, missing brute force protection, JWT tokens without expiration, storing tokens in localStorage, no session invalidation on logout, missing MFA, user enumeration vulnerabilities, and OAuth implementations that skip the state parameter. It ranked #2 in 2017 and dropped to #7 in 2021 because modern frameworks improved authentication defaults — but the underlying bugs still appear constantly in real apps.

Why is storing JWT tokens in localStorage dangerous?

localStorage is accessible to any JavaScript running on your page, including third-party scripts and injected code from XSS attacks. If an attacker can run JavaScript in your user's browser (through a single XSS vulnerability anywhere on your site), they can read the JWT from localStorage and use it to impersonate that user. Storing tokens in httpOnly cookies makes them invisible to JavaScript entirely — the browser sends them automatically with each request, but no script can read or steal them.

How do I protect my login endpoint from brute force attacks?

At minimum: rate limit by IP address (5-10 attempts per minute), add exponential backoff after failed attempts, lock accounts after a threshold (10-20 failures), and log failed login attempts with IP and timestamp for monitoring. For stronger protection, add CAPTCHA after the first few failures and implement IP reputation checks. In Next.js, you can use middleware to rate limit before the request even reaches your API route. Never rely on a single mechanism — layer them.

What JWT claims are required for secure token handling?

Every JWT should include: exp (expiration time — short-lived, 15 minutes to 1 hour for access tokens), iat (issued at), sub (subject — the user ID), and iss (issuer — your domain). Without exp, a stolen token is valid forever. Without sub scoped to a specific user, you may be vulnerable to token substitution attacks. Use refresh tokens (stored in httpOnly cookies) to issue new access tokens without forcing re-login.

What is user enumeration and why does it matter?

User enumeration is when your app reveals whether an email address is registered, either through different error messages ('Email not found' vs 'Wrong password') or different response times. An attacker can use this to build a list of valid accounts for credential stuffing attacks. The fix is simple: always return the same generic message ('Invalid credentials') and take the same amount of time to respond, whether the email exists or not. Constant-time comparison functions help with the timing side.


Authentication is one of those areas where the gap between "it works" and "it's secure" isn't obvious until something goes wrong. The bugs in this guide aren't edge cases — they're the exact issues behind real breaches at well-funded companies with dedicated security teams.

The good news: every single one of them is fixable in an afternoon.

Scan your repo for auth vulnerabilities → | Read the authentication failures reference →

OWASPauthenticationJWTsecurityNextJSSupabasevibe-coding