Auth is the one thing the model always writes. Sign up, sign in, reset password — it never forgets the form. What it forgets is the hundred quiet rules underneath that decide whether the form is a door or a wall. The door works. That is not the same thing as the door being locked.
Identification and authentication failures cover every way a login flow can be technically correct but practically broken. Missing rate limits, weak password rules, predictable reset tokens, session IDs that never expire, MFA that is optional and easy to skip. The form works. The guarantees behind the form do not.
What your AI actually built
You asked for login with email and password. The model built the route, the form, the session cookie, and the redirect. Every happy-path test passes. You tried it yourself and it let you in.
What it didn't build was rate limiting on the login endpoint, so an attacker can try a hundred thousand passwords a minute. No lockout, no captcha, no delay. The form works exactly the same on attempt one and attempt one million.
The JWT it hands out is signed, but the secret is 'secret' in an .env.example that got committed. The password reset token is a random number from Math.random. None of these are typos — they are all defaults the model copied from a tutorial that skipped security for readability.
How it gets exploited
An attacker sees your login page. They have a list of 10 million known breached email/password pairs from a public dump.
- 1Credential stuffingThey point a simple script at /api/auth/login and replay the breached pairs. No rate limit, no captcha, no lockout — the server answers every request instantly.
- 2Harvest the hitsRoughly 1 in 1,000 pairs succeeds, because users reuse passwords. In an hour they have 10,000 valid sessions on your app.
- 3Skip MFAMFA is optional and most users never enabled it. The ones who did get skipped. The ones who did not get logged in.
- 4Move in quietlyThe logins look legitimate — correct password, normal user agent, spread across IPs via a proxy pool. Nothing trips an alert because there is no alert.
Thousands of real user accounts taken over in a single overnight run, using credentials the attacker already had. No exploit was needed — the login form was the exploit.
Vulnerable vs Fixed
// app/api/auth/login/route.ts
export async function POST(req) {
const { email, password } = await req.json();
const user = await db.user.findUnique({ where: { email } });
if (!user || !(await bcrypt.compare(password, user.hash))) {
return Response.json({ error: 'Invalid' }, { status: 401 });
}
// Same response time, same error, no throttle.
const token = jwt.sign({ id: user.id }, 'secret');
return Response.json({ token });
}// app/api/auth/login/route.ts
export async function POST(req) {
const ip = req.headers.get('x-forwarded-for') ?? 'unknown';
const { email, password } = await req.json();
// Throttle per IP and per email. Fail closed.
if (await rateLimit.exceeded(ip, email)) {
return Response.json({ error: 'Too many attempts' }, { status: 429 });
}
const user = await db.user.findUnique({ where: { email } });
const ok = user && (await bcrypt.compare(password, user.hash));
if (!ok) return Response.json({ error: 'Invalid' }, { status: 401 });
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET!, {
expiresIn: '1h',
});
return Response.json({ token });
}Two additions — a real rate limiter keyed on both IP and email, and a JWT secret loaded from env with a real expiry. Credential stuffing dies at the first check. The rest of the flow is unchanged.
A real case
23andMe credential stuffing leaked 6.9 million profiles
Attackers replayed breached passwords against 23andMe logins with no throttle, then used the DNA Relatives feature to pivot from each compromised account to thousands of relatives.
Related reading
References
Prove your login actually locks people out.
Flowpatrol tests every auth flow the way an attacker would and reports the ones that let too much through. Five minutes. One URL.
Try it free