Rate Limiting Best Practices: API Protection and Abuse Prevention

TL;DR

The #1 rate limiting best practice is applying different limits for different endpoints. Auth and expensive work get strict caps. Public reads can be generous. After that: sliding windows, key the limit on the authenticated user rather than the IP alone, a real 429 with a Retry-After header, and Redis once you run more than one server.

"Rate limiting is your API's immune system. Without it, a single bad actor can bring down your entire service and bankrupt your cloud budget."

Why Rate Limiting Matters

Rate limiting protects against:

  • Brute force attacks: Login attempts, password resets
  • DDoS: Overwhelming your servers
  • Scraping: Automated data extraction
  • API abuse: Excessive usage beyond plan limits
  • Cost attacks: Running up your cloud bills

Best Practice 1: Different Limits for Different Endpoints 5 min

A login form and a public product listing are not the same risk, so they shouldn't share a budget. The login route is the one people forget, because it doesn't feel like an API.

Express rate limiting by endpoint
import rateLimit from 'express-rate-limit';

// General API: 100 requests per 15 minutes
const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
});

// Login: 5 attempts per 15 minutes
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  message: { error: 'Too many login attempts' },
  skipSuccessfulRequests: true,
});

// Password reset: 3 per hour
const resetLimiter = rateLimit({
  windowMs: 60 * 60 * 1000,
  max: 3,
});

// Expensive operations: 10 per hour
const expensiveLimiter = rateLimit({
  windowMs: 60 * 60 * 1000,
  max: 10,
});

// Apply limits
app.use('/api/', apiLimiter);
app.post('/api/auth/login', loginLimiter);
app.post('/api/auth/reset-password', resetLimiter);
app.post('/api/generate', expensiveLimiter);

Best Practice 2: Identify Users Correctly 3 min

IP alone stops working the moment people log in. One user on a train changes IP every few minutes. One attacker can sit behind a thousand.

User-based rate limiting
const userLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  keyGenerator: (req) => {
    // Use user ID for authenticated requests
    if (req.user?.id) {
      return `user:${req.user.id}`;
    }
    // Fall back to IP for unauthenticated
    return `ip:${req.ip}`;
  },
  skip: (req) => {
    // Skip rate limiting for admins
    return req.user?.role === 'admin';
  },
});

Best Practice 3: Use Redis for Distributed Systems 5 min

In-memory counters break as soon as you run a second instance. Each server counts only its own traffic, so three servers quietly means triple the limit you set.

Redis-based rate limiting
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();

const limiter = rateLimit({
  store: new RedisStore({
    sendCommand: (...args) => redisClient.sendCommand(args),
  }),
  windowMs: 15 * 60 * 1000,
  max: 100,
});

Best Practice 4: Return Proper Headers 2 min

Tell the caller what happened. A well-behaved integration backs off on its own if you hand it the numbers.

Rate limit response headers
// Response headers to include:
RateLimit-Limit: 100              // Max requests allowed
RateLimit-Remaining: 42           // Requests remaining
RateLimit-Reset: 1640000000       // When limit resets (Unix timestamp)
Retry-After: 120                  // Seconds until they can retry (on 429)

// Example 429 response
{
  "error": "Too many requests",
  "retryAfter": 120
}

Best Practice 5: Sliding Window Algorithm 3 min

A fixed window has an edge you can drive through. Send your full quota in the last second of one window and again in the first second of the next. You've used double the limit without breaking it.

AlgorithmProsCons
Fixed WindowSimple, low memoryBurst at window boundary
Sliding WindowSmooth, no burstMore complex
Token BucketAllows controlled burstsMore complex
Endpoint TypeRecommended Limit
General API100-1000/hour
Login5-10/15 minutes
Password reset3-5/hour
Email sending10/hour
AI/expensive10-50/hour
Public read1000+/hour

Official Resources: For comprehensive rate limiting guidance, see OWASP Denial of Service Cheat Sheet, Google Cloud Rate Limiting Strategies, and express-rate-limit documentation.

Should I rate limit by IP or user ID?

Both. User ID catches one account hammering you. IP catches brute force against accounts nobody has logged into yet. You need both, because plenty of attacks come from one IP driving many accounts.

How do I handle rate limiting behind a proxy?

Configure your app to trust the proxy and read the real IP from X-Forwarded-For header. In Express: app.set('trust proxy', 1). Don't let it trust arbitrary headers from anyone.

Should I tell users when they are rate limited?

Yes. Return a 429 with a Retry-After header and a message that says what happened. Real users and automated clients both back off correctly when you tell them.

Further Reading

Put these practices into action with our step-by-step guides.

Check Your Rate Limiting

Scan your API for missing rate limits.

Best Practices

Rate Limiting Best Practices: API Protection and Abuse Prevention