How Do I Add Rate Limiting to AI-Generated Login Endpoints?
I asked an AI to generate authentication endpoints for my Express.js application. It gave me /login, /register, and /forgot-password routes that worked perfectly on the first try. But when I ran a security audit, I found something disturbing: no rate limiting, no account lockout, nothing to prevent brute-force attacks.
This is a common pattern with AI-generated code. The endpoints work, but they’re missing critical security layers. Let me show you why this happens and how to fix it.
The Problem: AI Generates Auth Without Protection
Here’s what a typical AI-generated login endpoint looks like:
import { Router } from 'express';import { compare } from 'bcrypt';import { sign } from 'jsonwebtoken';import { findUserByEmail } from '../services/userService';
const router = Router();
router.post('/login', async (req, res) => { const { email, password } = req.body;
const user = await findUserByEmail(email); if (!user) { return res.status(401).json({ error: 'Invalid credentials' }); }
const isValid = await compare(password, user.passwordHash); if (!isValid) { return res.status(401).json({ error: 'Invalid credentials' }); }
const token = sign({ userId: user.id }, process.env.JWT_SECRET!); res.json({ token });});
export default router;This code is functionally correct. It validates credentials and returns a JWT token. But notice what’s missing: there’s absolutely nothing preventing an attacker from making thousands of login attempts per second.
Why AI Skips Rate Limiting
AI models have a “happy path bias.” They focus on making features work rather than defending against attacks. This happens for several reasons:
1. Prompt Context Limitations
When you ask for a login endpoint, the AI optimizes for the immediate request. Rate limiting requires infrastructure decisions (in-memory store vs Redis, configuration values, error responses) that expand the scope significantly.
2. Training Data Patterns
Most tutorial code and Stack Overflow answers show authentication without rate limiting. AI models learn from this data and reproduce the same patterns.
3. No Security Mindset by Default
Unless you explicitly ask for security measures, AI treats them as optional additions rather than core requirements.
A Reddit developer put it bluntly: “/login, /register, /forgot-password — AI generates them all without brute-force protection. No rate limiting, no account lockout, nothing.”
Security Risks Without Rate Limiting
Without rate limiting, your authentication endpoints are vulnerable to:
Credential Stuffing: Attackers test leaked username/password combinations from other breaches against your system. Without rate limiting, they can test millions of combinations.
Brute-Force Attacks: If an attacker knows a username, they can systematically try common passwords until they gain access.
Denial of Service: A flood of login requests can overwhelm your server, making the application unavailable to legitimate users.
Account Enumeration: By observing response times and patterns, attackers can determine which emails are registered in your system.
Adding Rate Limiting with express-rate-limit
The express-rate-limit package provides a straightforward solution. Here’s how to transform the vulnerable endpoint into a protected one:
import rateLimit from 'express-rate-limit';import RedisStore from 'rate-limit-redis';import { createClient } from 'redis';
const redisClient = createClient({ url: process.env.REDIS_URL});
await redisClient.connect();
export const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 5, // 5 attempts per window per IP standardHeaders: true, legacyHeaders: false, message: { error: 'Too many login attempts', retryAfter: '15 minutes' }, store: new RedisStore({ sendCommand: (...args: string[]) => redisClient.sendCommand(args) }), skipSuccessfulRequests: true // Don't count successful logins});
export const passwordResetLimiter = rateLimit({ windowMs: 60 * 60 * 1000, // 1 hour max: 3, // Only 3 password reset requests per hour standardHeaders: true, legacyHeaders: false, message: { error: 'Too many password reset requests', retryAfter: '1 hour' }, store: new RedisStore({ sendCommand: (...args: string[]) => redisClient.sendCommand(args) })});Now apply the middleware to your routes:
import { Router } from 'express';import { compare } from 'bcrypt';import { sign } from 'jsonwebtoken';import { findUserByEmail } from '../services/userService';import { loginLimiter, passwordResetLimiter } from '../middleware/rateLimiter';
const router = Router();
router.post('/login', loginLimiter, async (req, res) => { const { email, password } = req.body;
const user = await findUserByEmail(email); if (!user) { return res.status(401).json({ error: 'Invalid credentials' }); }
const isValid = await compare(password, user.passwordHash); if (!isValid) { return res.status(401).json({ error: 'Invalid credentials' }); }
const token = sign({ userId: user.id }, process.env.JWT_SECRET!); res.json({ token });});
router.post('/forgot-password', passwordResetLimiter, async (req, res) => { // Password reset logic here});
export default router;Per-User Rate Limiting for Better Protection
IP-based rate limiting has a flaw: multiple users behind the same proxy or VPN share the same limit. A better approach combines IP and user-based limiting:
import rateLimit from 'express-rate-limit';import RedisStore from 'rate-limit-redis';
const redisClient = createClient({ url: process.env.REDIS_URL });await redisClient.connect();
const keyGenerator = (req: Request): string => { const ip = req.ip || req.connection.remoteAddress || 'unknown'; const email = req.body?.email || 'anonymous'; return `${ip}:${email}`;};
export const perUserLoginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5, keyGenerator, standardHeaders: true, legacyHeaders: false, message: { error: 'Too many login attempts for this account', retryAfter: '15 minutes' }, store: new RedisStore({ sendCommand: (...args: string[]) => redisClient.sendCommand(args) })});Adding Account Lockout for Defense in Depth
Rate limiting alone isn’t enough. Determined attackers can wait out the timeout. Add account lockout as a second layer:
import { findUserByEmail, updateUser } from './userService';
const MAX_FAILED_ATTEMPTS = 5;const LOCKOUT_DURATION = 30 * 60 * 1000; // 30 minutes
interface LockoutStatus { isLocked: boolean; remainingTime?: number; attemptsRemaining: number;}
export async function checkLockoutStatus(email: string): Promise<LockoutStatus> { const user = await findUserByEmail(email);
if (!user) { return { isLocked: false, attemptsRemaining: MAX_FAILED_ATTEMPTS }; }
if (user.lockedUntil && new Date() < new Date(user.lockedUntil)) { const remainingTime = Math.ceil( (new Date(user.lockedUntil).getTime() - Date.now()) / 1000 / 60 ); return { isLocked: true, remainingTime, attemptsRemaining: 0 }; }
return { isLocked: false, attemptsRemaining: MAX_FAILED_ATTEMPTS - (user.failedLoginAttempts || 0) };}
export async function recordFailedAttempt(email: string): Promise<void> { const user = await findUserByEmail(email); if (!user) return;
const newFailedAttempts = (user.failedLoginAttempts || 0) + 1;
if (newFailedAttempts >= MAX_FAILED_ATTEMPTS) { await updateUser(user.id, { failedLoginAttempts: newFailedAttempts, lockedUntil: new Date(Date.now() + LOCKOUT_DURATION) }); } else { await updateUser(user.id, { failedLoginAttempts: newFailedAttempts }); }}
export async function clearFailedAttempts(userId: string): Promise<void> { await updateUser(userId, { failedLoginAttempts: 0, lockedUntil: null });}Now integrate lockout into your login endpoint:
import { Router } from 'express';import { compare } from 'bcrypt';import { sign } from 'jsonwebtoken';import { findUserByEmail } from '../services/userService';import { loginLimiter } from '../middleware/rateLimiter';import { checkLockoutStatus, recordFailedAttempt, clearFailedAttempts} from '../services/accountLockout';
const router = Router();
router.post('/login', loginLimiter, async (req, res) => { const { email, password } = req.body;
// Check lockout status first const lockoutStatus = await checkLockoutStatus(email);
if (lockoutStatus.isLocked) { return res.status(423).json({ error: 'Account temporarily locked', retryAfter: `${lockoutStatus.remainingTime} minutes` }); }
const user = await findUserByEmail(email);
if (!user) { return res.status(401).json({ error: 'Invalid credentials' }); }
const isValid = await compare(password, user.passwordHash);
if (!isValid) { await recordFailedAttempt(email); return res.status(401).json({ error: 'Invalid credentials', attemptsRemaining: lockoutStatus.attemptsRemaining - 1 }); }
// Reset failed attempts on successful login await clearFailedAttempts(user.id);
const token = sign({ userId: user.id }, process.env.JWT_SECRET!); res.json({ token });});
export default router;Best Practices Summary
When adding rate limiting to authentication endpoints, follow these guidelines:
-
Use Redis for Distributed Systems: In-memory stores don’t work across multiple servers. Redis provides consistent rate limiting in distributed deployments.
-
Combine IP and User-Based Limits: Prevent attackers from bypassing limits by using different IPs while targeting the same account.
-
Implement Progressive Delays: Increase the lockout duration with each failed attempt. Start with 15 minutes, then escalate to an hour, then 24 hours.
-
Don’t Reveal Account Existence: Return the same “Invalid credentials” message whether the email exists or not.
-
Log Suspicious Activity: Track failed login attempts in your security logs for analysis and alerting.
-
Skip Successful Requests: Don’t count successful logins against the rate limit. This prevents legitimate users from being locked out.
-
Set Appropriate Limits: 5 attempts per 15 minutes for login, 3 per hour for password reset. Adjust based on your security requirements.
The Complete Picture
Here’s what your secured authentication system should include:
import rateLimit from 'express-rate-limit';import RedisStore from 'rate-limit-redis';import { createClient } from 'redis';
const redisClient = createClient({ url: process.env.REDIS_URL });await redisClient.connect();
// IP-based rate limiting (first layer)export const ipLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10, // Allow more requests at IP level standardHeaders: true, legacyHeaders: false, store: new RedisStore({ sendCommand: (...args: string[]) => redisClient.sendCommand(args) })});
// Per-user rate limiting (second layer)export const userLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5, keyGenerator: (req) => `${req.ip}:${req.body?.email}`, standardHeaders: true, legacyHeaders: false, skipSuccessfulRequests: true, store: new RedisStore({ sendCommand: (...args: string[]) => redisClient.sendCommand(args) })});
// Apply both limitersexport const authLimiter = [ipLimiter, userLimiter];Rate limiting isn’t optional for authentication endpoints. AI-generated code gets you 80% of the way there, but that last 20% - the security layer - is critical. Take the time to add it, or your users’ accounts will be at risk.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 express-rate-limit Documentation
- 👨💻 OWASP Authentication Cheat Sheet
- 👨💻 Reddit Discussion: AI auth without rate limiting
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments