SecurityBlog Post

Your Login Form Will Let Anyone Guess Passwords All Day

AI-built login and OTP endpoints usually have no limit on how many times someone can try. That turns password guessing and OTP guessing into a solved problem for an attacker.

September 4, 2026
3 min read
Your Login Form Will Let Anyone Guess Passwords All Day
Is your AI-built app exposed? Get a professional vibe coding audit and ship to production with confidence.

Your login endpoint will accept a thousand password guesses in a minute, and it will not blink.

AI coding tools are good at building the happy path. You ask for a login route. You get a route that checks the email and password, then returns a token. Nobody asks the tool "what if someone calls this 10,000 times in a row." So it never adds a check for that.

The same thing happens with one-time codes sent by SMS or email. A 6-digit OTP has only a million possible values. If nothing stops repeated guesses, a script can burn through them in minutes.

This is not a theoretical bug. It is one of the first things an attacker tries, because it is one of the easiest to automate. A tool like Hydra or a plain Python loop can hammer your /login route with a list of common passwords. Or it can hammer your /verify-otp route with every 6-digit number in order. If your app has no limit, one of those attempts eventually works.

Here's what the broken version looks like:

app.post('/login', async (req, res) => {
  const { email, password } = req.body;
  const user = await db.users.findOne({ email });

  if (!user || !(await bcrypt.compare(password, user.hash))) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const token = signToken(user);
  res.json({ token });
});

Nothing here tracks how many times an email or IP has tried. Call it once, call it ten thousand times, the server treats every request the same.

Here's the fix. Add a rate limiter in front of the route, keyed by IP and by the email being tried, and lock things down after a few failures:

const rateLimit = require('express-rate-limit');

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  keyGenerator: (req) => `${req.ip}:${req.body.email}`,
  message: { error: 'Too many attempts, try again later' },
});

app.post('/login', loginLimiter, async (req, res) => {
  const { email, password } = req.body;
  const user = await db.users.findOne({ email });

  if (!user || !(await bcrypt.compare(password, user.hash))) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const token = signToken(user);
  res.json({ token });
});

Five attempts per 15 minutes per email is a reasonable start. Tune it for your app, but the point is that some limit exists. Do the same for any OTP verification route, password reset route, and signup route. Those all get hit by the same kind of automated guessing.

One thing to watch for: rate limiting by IP alone is not enough. An attacker can rotate IPs. Key your limiter by the account being targeted too. That way guessing against one user's email gets shut down even from a hundred different addresses.

To check your own app, open every route that takes a password, an OTP, or a reset token. Look for a rate limiter, a lockout counter, or a CAPTCHA sitting in front of it. If you can call the route in a loop from a script and it never pushes back, that's the gap. Try it yourself with curl in a loop against a test account. Watch how many attempts it takes before anything stops you.

If you want a second set of eyes on this, get a free assessment. We'll check your auth routes and tell you what's open, within 24 hours.

VibeAudits audits apps built with Cursor, Lovable, Bolt, Claude Code, Replit, and other AI tools.

VibeAudits

Security Experts

Worried your vibe-coded app has issues like this?

We run professional code audits for SaaS apps and AI features built with Cursor, Claude, Copilot, Lovable and Replit. We find the security and reliability problems before your customers (or attackers) do, then hand you a fix-ready report.