Skip to content

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:

routes/auth.ts
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:

middleware/rateLimiter.ts
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:

routes/auth.ts
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:

middleware/perUserRateLimiter.ts
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:

services/accountLockout.ts
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:

routes/auth.ts
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:

  1. Use Redis for Distributed Systems: In-memory stores don’t work across multiple servers. Redis provides consistent rate limiting in distributed deployments.

  2. Combine IP and User-Based Limits: Prevent attackers from bypassing limits by using different IPs while targeting the same account.

  3. Implement Progressive Delays: Increase the lockout duration with each failed attempt. Start with 15 minutes, then escalate to an hour, then 24 hours.

  4. Don’t Reveal Account Existence: Return the same “Invalid credentials” message whether the email exists or not.

  5. Log Suspicious Activity: Track failed login attempts in your security logs for analysis and alerting.

  6. Skip Successful Requests: Don’t count successful logins against the rate limit. This prevents legitimate users from being locked out.

  7. 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:

middleware/authSecurity.ts
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 limiters
export 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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments