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 automaticallyThe 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 cookieThe 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:
- The attacker obtained the Uber contractor's credentials — likely through a data broker or prior breach.
- They attempted to log in repeatedly. MFA was enabled, so each attempt pushed a notification to the contractor's phone.
- 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."
- 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 →
Related Posts
OWASP A01 Broken Access Control Guide
Broken access control is the #1 OWASP risk. This guide explains IDOR, missing auth checks, JWT tampering, and how to fix them with real Next.js code examples.
OWASP A02 Cryptographic Failures Guide
OWASP A02:2021 Cryptographic Failures is the #2 web vulnerability. Learn how plaintext passwords, weak hashing, and hardcoded keys expose your users — with real code examples.
OWASP A08 Data Integrity Failures Guide
OWASP A08:2021 covers CI/CD attacks, unsafe deserialization, and missing SRI. Learn how integrity failures happen and how to prevent them in your pipeline.