SecurityJul 27, 2026 · 5 min read

Why Cursor Writes Login Endpoints With No Rate Limiting

AI editors generate login endpoints with correct bcrypt hashing and proper JWTs, then leave out any limit on attempts (CWE-307). Here is why it happens and the two-layer fix.

SW

SafeWeave Team

TL;DR

  • Every login endpoint I have had Cursor and Claude Code generate came back with zero brute-force protection (CWE-307).
  • An attacker with a leaked password list can run thousands of guesses a minute against a perfectly correct endpoint, and nothing stops them or tells you it happened.
  • The fix is about ten lines: a rate limiter on the route, plus a per-account failure counter that survives IP rotation.

I asked Claude Code to build a login endpoint last month. It came back with bcrypt at a sane cost factor, a signed JWT with an expiry, and a generic "invalid credentials" message that does not leak whether the email exists.

Solid code. Better than what I would have written by hand at 11pm.

Then I pointed a script at it and made 4,000 password guesses in under two minutes. The endpoint answered every single one.

The Vulnerable Code

The bug is not in what this code does. It is in what it never does: nothing anywhere limits how many times the same client can call it. That is CWE-307, improper restriction of excessive authentication attempts.

app.post('/api/login', async (req, res) => {
  const { email, password } = req.body;
  const user = await User.findOne({ email });
  if (!user) return res.status(401).json({ error: 'Invalid credentials' });

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

  const token = jwt.sign({ sub: user.id }, process.env.JWT_SECRET, { expiresIn: '1h' });
  res.json({ token });
});

Read that looking for a mistake and you will not find one. The hashing is right. The error messages do not leak user existence. The token expires. There is simply no ceiling on attempts.

People often argue bcrypt is the defense here, and it does help. Each guess costs the server roughly 250ms of CPU. But that is a throttle, not a wall. Fire 50 requests in parallel and you are back to hundreds of guesses per second, except now every guess is also eating your own CPU. The unprotected login route becomes a denial-of-service lever at the same time.

Catch these vulnerabilities automatically with SafeWeave

SafeWeave runs 8 security scanners in parallel — SAST, secrets, dependencies, IaC, containers, DAST, license, and posture — right inside your AI editor. One command, zero config.

Start Scanning Free

Why This Keeps Happening

Rate limiting lives in a different file from the thing you asked for, and the model writes only the file you asked about. Prompt it with "build a login endpoint" and you get a login endpoint: a route handler that takes credentials and returns a token. Rate limiting is infrastructure that wraps the route, and no tutorial puts it inside the handler.

Look at where the training data comes from. Auth tutorials optimize for the reader having a working login by the last paragraph. Rate limiting adds a dependency, a shared store, and an awkward aside about why in-memory counters break the moment you run more than one process. So it gets cut, or exiled to a "production considerations" section at the bottom that most readers skip and most scrapers treat as unrelated content.

There is a second reason and it is the uncomfortable one. Missing rate limiting produces no symptom. A hardcoded secret shows up in a diff. SQL injection breaks on an apostrophe. An endpoint with no brute-force protection works perfectly, passes every test you write, and behaves identically whether you are the real user or the ten-thousandth guess of an automated script. You find out from a credential-stuffing incident, not from your test suite.

Try SafeWeave in 30 seconds

npx safeweave-mcp

Works with Cursor, Claude Code, Windsurf, and VS Code. No signup required for the free tier — 3 scanners, unlimited scans.

The Fix

Two layers. A rate limiter on the route stops the fast attack, and a per-account failure counter stops the slow one that rotates IPs.

The route limiter:

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

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 10,
  standardHeaders: true,
  skipSuccessfulRequests: true,
  message: { error: 'Too many attempts, try again later' }
});

app.post('/api/login', loginLimiter, async (req, res) => { /* ... */ });

The skipSuccessfulRequests option matters more than it looks. Without it, one real user on a shared office IP burns the budget for everyone else behind that NAT.

The IP limiter alone is not enough. A credential-stuffing run rotates through residential proxies and rarely reuses an address, so per-IP counting just sees ten thousand first attempts. Count failures per account too:

const key = `login:fail:${user.id}`;
const fails = await redis.incr(key);
if (fails === 1) await redis.expire(key, 900);
if (fails > 5) return res.status(429).json({ error: 'Account temporarily locked' });

// after a successful password check:
await redis.del(key);

Python, same shape:

from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

limiter = Limiter(get_remote_address, app=app,
                  storage_uri="redis://localhost:6379")

@app.post("/api/login")
@limiter.limit("10 per 15 minutes")
def login():
    ...

One warning that catches people. The default in-memory store in both libraries resets on restart and shares nothing between processes. Run four instances behind a load balancer and an attacker quietly gets four times your limit, and every deploy hands them a fresh budget. Point it at Redis.

FAQ

Q: Does bcrypt protect against brute force on its own? A: No. bcrypt makes each guess expensive, roughly 250ms at cost 12, but an attacker running requests in parallel still manages hundreds of guesses per second. It also means every guess consumes your CPU, so an unlimited login route doubles as a denial-of-service vector.

Q: Is IP-based rate limiting enough? A: Not by itself. Credential-stuffing tools rotate through residential proxy pools, so per-IP counters almost never see a repeat address. Pair the IP limit with a per-account failure counter that locks after about 5 failures, and keep the count in a shared store like Redis so it survives restarts and multiple processes.

Q: Which endpoints besides login need this? A: Password reset, email verification, OTP or 2FA verification, and signup. Anything that checks a secret or triggers an email or SMS. OTP endpoints are the worst case: a 6-digit code is a million combinations, which is minutes of unlimited guessing.

I have been running SafeWeave for this. It hooks into Cursor and Claude Code as an MCP server, and its posture check flags auth routes with no rate limiting before I move on. This one is harder to catch with grep-style tooling than most vulnerabilities, because there is no bad pattern to match, only an absent one. Whatever you use, what matters is checking at the moment the endpoint gets written, not after the credential-stuffing alert.

Secure your AI-generated code with SafeWeave

8 security scanners running in parallel, right inside your AI editor. SAST, secrets, dependencies, IaC, containers, DAST, license compliance, and security posture — all in one command.

No credit card required · 3 scanners free forever · Runs locally on your machine