Three years ago, I woke up to a $1,200 AWS bill. Someone had found my staging API, scraped every endpoint for six hours straight, and triggered enough Lambda invocations to fund a small vacation. No rate limiting. No IP blocking. Just open season.
That bill taught me more about API security than any tutorial ever could. Since then, I've built rate limiting into every API I touch—not as an afterthought, but as foundational infrastructure. I've seen credential-stuffing attacks stop cold at 100 requests per 15 minutes. I've watched DDoS attempts peter out against token buckets. I've helped teams prevent the exact disaster I stumbled into.
This guide covers what I wish I'd known before that bill arrived: how to implement production-grade rate limiting, which algorithms to use when, and how to layer rate limiting with authentication and authorization so your API isn't just protected—it's defensible. Every code example here runs in production. Every attack scenario is real. And every configuration recommendation comes from incidents I've responded to or prevented.
Why API Rate Limiting Matters (Security + Performance)
Rate limiting isn't just a nice-to-have feature you add when traffic scales. It's the first line of defense against attacks that can crater your service, drain your budget, or expose your users' data.
Here's what happens without it:
Credential stuffing becomes unstoppable. Attackers try 10,000 stolen username/password pairs against your login API. Without rate limits, they burn through the list in minutes and compromise accounts before you notice the spike. With rate limiting, they're throttled to 20 attempts per hour per IP, turning a 10-minute attack into a 500-hour exercise in futility.
DDoS attacks crater your service. An attacker hammers your endpoint with distributed traffic. Your database connection pool saturates, legitimate users get timeouts, and you're paged at 3 AM. Rate limiting caps requests per IP so the attack accomplishes nothing.
Scraping drains your budget. If you're on pay-per-request infrastructure (Lambda, Cloud Run), every scraped request costs real money. Rate limiting caps access without breaking legitimate integrations.
GitHub limits unauthenticated API requests to 60 per hour. Stripe throttles test-mode API calls to prevent accidental load testing. Twitter's API has per-endpoint rate limits ranging from 15 to 900 requests per 15-minute window. These aren't arbitrary numbers—they're calculated thresholds that balance access with abuse prevention.
Rate limiting protects three things: your infrastructure, your users, and your budget. The question isn't whether to implement it. It's how to implement it correctly.
Understanding Rate Limiting Fundamentals
At its core, rate limiting is simple: track how many requests a client makes and reject requests when they exceed a threshold.
The complexity comes from three decisions:
1. What to count: Requests per time window. Common examples:
- 100 requests per minute (API burst protection)
- 1,000 requests per hour (moderate usage cap)
- 10,000 requests per day (generous fair-use limit)
- 1 request per second per endpoint (strict operation-level throttling)
2. Who to track: The granularity level determines who hits limits together:
- Per-IP address — Simplest, but breaks down with NAT, VPNs, or shared office networks
- Per-user — Requires authentication, but gives each user a fair quota
- Per-API-key — Standard for external integrations; each client app gets isolated limits
- Global — Single shared limit for all clients (rare, used for fragile endpoints)
3. What to do when exceeded: Most APIs return HTTP 429 (Too Many Requests) with a Retry-After header indicating when the client can try again. Some APIs queue excess requests. Some drop them silently (bad practice—always signal the rejection).
Rate limiting vs throttling: The terms are often used interchangeably, but there's a subtle difference. Rate limiting enforces a maximum request count per time window and rejects excess requests. Throttling reduces the processing speed of requests but still serves them (think of throttling as slowing down traffic, rate limiting as closing the gate).
I use "rate limiting" for most cases because rejecting excess requests is simpler and more predictable than throttling, which can introduce weird latency patterns.
The key insight: rate limiting is stateful. You're tracking request counts over time, which means you need somewhere to store that state. In-memory counters work for single-server deployments. Distributed systems need shared state in Redis or a similar data store.
Rate Limiting Algorithms Explained
There are four main rate limiting algorithms. Each has different trade-offs around burst handling, implementation complexity, and memory usage.
Fixed Window
How it works: Divide time into fixed intervals (e.g., every minute starts at :00 seconds). Count requests in each window. Reset the counter when the window closes.
Window 1 (00:00-00:59): 98 requests → ALLOWED
Window 2 (01:00-01:59): 2 requests → ALLOWED (counter reset at 01:00)
Pros:
- Simplest to implement (single counter per client, reset on interval)
- Minimal memory usage
- Easy to reason about
Cons:
- Burst problem: A client can send 100 requests at 00:59 and 100 more at 01:00, effectively getting 200 requests in 2 seconds while staying under a "100 per minute" limit.
- Not ideal for strict burst protection
When to use: Low-traffic APIs where occasional bursts don't matter. Internal APIs where you trust the client not to exploit window boundaries.
Sliding Window
How it works: Instead of fixed time intervals, use a rolling window. For "100 requests per minute," check the count of requests in the last 60 seconds from now, not from the top of the minute.
At 01:30, count requests from 00:30 to 01:30
At 01:31, count requests from 00:31 to 01:31
Pros:
- Smooth rate limiting (no burst at window boundaries)
- More accurate enforcement of per-minute/hour limits
Cons:
- More complex to implement (need to track timestamps of individual requests)
- Higher memory usage (store request timestamps, not just a counter)
When to use: Public APIs where you need strict enforcement and can't tolerate boundary exploits.
Token Bucket
How it works: Each client gets a bucket that holds N tokens. Every request consumes 1 token. The bucket refills at a fixed rate (e.g., 10 tokens per second). If the bucket is empty, reject the request.
Bucket capacity: 100 tokens
Refill rate: 10 tokens/second
Client makes 50 requests instantly → 50 tokens consumed, 50 remain
Client waits 5 seconds → bucket refills to 100 tokens (capped at capacity)
Client makes 120 requests → first 100 succeed, next 20 rejected
Pros:
- Handles bursts gracefully (bucket capacity allows short bursts without rejection)
- Industry standard (used by AWS API Gateway, Stripe, many others)
- Intuitive mental model
Cons:
- Slightly more complex than fixed window (track token count + last refill time)
- Bucket capacity and refill rate must be tuned together
When to use: Most production APIs. Default choice unless you have a specific reason to use something else.
My default: Token bucket. It balances simplicity with burst handling and matches how most developers think about rate limiting. (There's a fourth algorithm—leaky bucket—but it's rarely needed for web APIs; use it only if you're shaping traffic for downstream systems that explicitly can't handle any bursts.)
Implementing Token Bucket Rate Limiting in Node.js
Here's a production-ready token bucket implementation using Express and Redis. This scales across multiple servers because rate limit state lives in Redis, not in-process memory. If you're deploying this to production, I walk through the complete Node.js + Docker + Nginx setup on a VPS—rate limiting fits naturally into that stack.
First, install dependencies:
npm install express redis express-rate-limit rate-limit-redis
Basic setup with express-rate-limit and Redis:
const express = require('express');
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const redis = require('redis');
const app = express();
const redisClient = redis.createClient({
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
});
// Public: 100 requests per 15 minutes per IP
const publicLimiter = rateLimit({
store: new RedisStore({ client: redisClient, prefix: 'rl:public:' }),
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
handler: (req, res) => {
res.status(429).json({
error: 'Too many requests',
retryAfter: req.rateLimit.resetTime,
});
},
});
app.use('/api/public/', publicLimiter);
// Authenticated: 1000 requests per hour per user
const authenticatedLimiter = rateLimit({
store: new RedisStore({ client: redisClient, prefix: 'rl:user:' }),
windowMs: 60 * 60 * 1000,
max: 1000,
keyGenerator: (req) => req.user?.id || req.ip,
skip: (req) => req.user?.role === 'admin',
});
app.use('/api/auth/', authenticatedLimiter);
// Admin: 50 per hour + IP whitelist
const adminLimiter = rateLimit({
store: new RedisStore({ client: redisClient, prefix: 'rl:admin:' }),
windowMs: 60 * 60 * 1000,
max: 50,
skip: (req) => {
const allowedIPs = (process.env.ADMIN_IP_WHITELIST || '').split(',');
return allowedIPs.includes(req.ip);
},
});
app.use('/api/admin/', adminLimiter);
Custom token bucket (if you need cost-based limiting):
class TokenBucket {
constructor(capacity, refillRate, redisClient, keyPrefix) {
this.capacity = capacity;
this.refillRate = refillRate;
this.redisClient = redisClient;
this.keyPrefix = keyPrefix;
}
async consume(clientId, tokens = 1) {
const key = `${this.keyPrefix}:${clientId}`;
const now = Date.now();
const data = await this.redisClient.get(key);
let bucket = data ? JSON.parse(data) : { tokens: this.capacity, lastRefill: now };
const timeElapsed = (now - bucket.lastRefill) / 1000;
bucket.tokens = Math.min(this.capacity, bucket.tokens + (timeElapsed * this.refillRate));
bucket.lastRefill = now;
if (bucket.tokens >= tokens) {
bucket.tokens -= tokens;
await this.redisClient.setex(key, 3600, JSON.stringify(bucket));
return { allowed: true, tokensRemaining: bucket.tokens };
}
const retryAfter = Math.ceil((tokens - bucket.tokens) / this.refillRate);
return { allowed: false, retryAfter };
}
}
const bucket = new TokenBucket(100, 10, redisClient, 'rl:custom');
app.post('/api/expensive-operation', async (req, res) => {
const result = await bucket.consume(req.ip, 5); // expensive operations cost more tokens
if (!result.allowed) {
return res.status(429).json({ error: 'Rate limit exceeded', retryAfter: result.retryAfter });
}
res.json({ success: true });
});
This gives you:
- Distributed rate limiting across multiple servers (Redis-backed)
- Different limits for public, authenticated, and admin endpoints
- Proper HTTP 429 responses with retry timing
- Configurable via environment variables
- Testable
For a containerized deployment, Redis runs in its own container alongside your Node.js app—I cover the multi-container orchestration patterns that make this straightforward.
Rate Limiting in Production: Configuration Strategies
The hard part isn't implementing rate limiting—it's choosing the right limits. Too strict and you block legitimate users. Too loose and you don't stop attacks.
Here's how I configure limits for different API tiers, with rationale for each number:
Public Endpoints (Unauthenticated)
100 requests per 15 minutes per IP
A typical web app makes 10-20 API calls per page load. A user browsing 5 pages hits 50-100 requests—that's legitimate. Stricter limits for sensitive operations:
- Login: 10 requests per 15 min per IP (prevents brute force)
- Registration: 5 requests per 15 min per IP (prevents account spam)
- Password reset: 3 requests per hour per IP
Authenticated Endpoints
1,000 requests per hour per user
Power users running scripts make 10-20 requests per minute (600-1,200/hour). 1,000 is generous for legitimate automation, tight enough to stop runaway loops. Per-user tracking survives IP changes (mobile networks, VPNs).
Tiered limits:
- Free: 1,000/hour
- Paid: 10,000/hour
- Enterprise: 100,000/hour with monitoring (no true "unlimited"—detect compromised keys before they crater infrastructure)
Admin Endpoints
50 requests per hour + IP whitelist
Admin endpoints are high-value targets. Combine strict rate limits with IP whitelisting:
const adminAllowedIPs = ['203.0.113.50', '203.0.113.51', '127.0.0.1'];
const adminLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 50,
skip: (req) => !adminAllowedIPs.includes(req.ip),
handler: (req, res) => {
console.error(`Admin rate limit exceeded: ${req.ip} ${req.path}`);
res.status(429).json({ error: 'Admin endpoint rate limit exceeded' });
},
});
Response Headers and Bypass Mechanisms
Return rate limit info so clients can self-regulate:
app.use((req, res, next) => {
res.on('finish', () => {
if (req.rateLimit) {
res.set({
'RateLimit-Limit': req.rateLimit.limit,
'RateLimit-Remaining': req.rateLimit.remaining,
'RateLimit-Reset': new Date(req.rateLimit.resetTime).toISOString(),
});
}
});
next();
});
For incidents, implement a bypass mechanism (ops team shouldn't be blocked when debugging outages):
const bypassToken = process.env.RATE_LIMIT_BYPASS_TOKEN;
const limiter = rateLimit({
skip: (req) => req.headers['x-bypass-token'] === bypassToken,
});
API Security Beyond Rate Limiting
Rate limiting is one layer in a security stack. It stops volume-based attacks (DDoS, brute force, scraping). But it doesn't prevent attacks that stay under the limit. Production security hardening goes deeper—least-privilege users, read-only filesystems, dropped capabilities—but those container-level protections complement (not replace) application-level security.
Here's what you need alongside rate limiting:
Authentication: Who Are You?
JWT (JSON Web Tokens) — Standard for stateless authentication. Server issues a signed token, client includes it in subsequent requests, server verifies the signature.
const jwt = require('jsonwebtoken');
// Login endpoint
app.post('/api/auth/login', async (req, res) => {
const { username, password } = req.body;
// Verify credentials (omitted for brevity)
const user = await verifyCredentials(username, password);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Issue JWT
const token = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
res.json({ token });
});
// Middleware to verify JWT
function requireAuth(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
res.status(401).json({ error: 'Invalid token' });
}
}
app.get('/api/protected', requireAuth, (req, res) => {
res.json({ message: `Hello, user ${req.user.userId}` });
});
OAuth 2.0 / OIDC — For third-party integrations. In 2026, OIDC (OpenID Connect, built on OAuth 2.0) is the standard. Use libraries like passport with passport-oauth2 strategy instead of rolling your own.
API Keys — For programmatic access. Generate random tokens, store them hashed (like passwords), and verify on each request:
const crypto = require('crypto');
async function createApiKey(userId) {
const key = crypto.randomBytes(32).toString('hex');
const hash = crypto.createHash('sha256').update(key).digest('hex');
await db.query('INSERT INTO api_keys (user_id, key_hash) VALUES ($1, $2)', [userId, hash]);
return key; // Return once; user must save it
}
async function verifyApiKey(req, res, next) {
const key = req.headers['x-api-key'];
if (!key) return res.status(401).json({ error: 'API key required' });
const hash = crypto.createHash('sha256').update(key).digest('hex');
const result = await db.query('SELECT user_id FROM api_keys WHERE key_hash = $1', [hash]);
if (result.rows.length === 0) return res.status(401).json({ error: 'Invalid API key' });
req.user = { id: result.rows[0].user_id };
next();
}
Authorization: What Can You Do?
Authentication tells you who the user is. Authorization decides what they can access.
Role-Based Access Control (RBAC):
function requireRole(allowedRoles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
app.delete('/api/users/:id', requireAuth, requireRole(['admin']), (req, res) => {
// Only admins can delete users
});
Resource-level permissions:
RBAC isn't enough when users should only access their own resources.
app.get('/api/projects/:id', requireAuth, async (req, res) => {
const project = await db.query('SELECT * FROM projects WHERE id = $1', [req.params.id]);
if (project.rows.length === 0) {
return res.status(404).json({ error: 'Project not found' });
}
// Check ownership
if (project.rows[0].owner_id !== req.user.userId && req.user.role !== 'admin') {
return res.status(403).json({ error: 'You do not own this project' });
}
res.json(project.rows[0]);
});
Input Validation: Never Trust the Client
Validate every input. Reject requests with malformed data before they touch your database or business logic.
const { body, param, validationResult } = require('express-validator');
app.post('/api/users',
[
body('email').isEmail().normalizeEmail(),
body('password').isLength({ min: 8 }),
body('age').optional().isInt({ min: 0, max: 120 }),
],
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// Process valid input
}
);
This prevents SQL injection, XSS, and data corruption from malformed inputs.
HTTPS and Security Headers
Enforce TLS 1.3 (or 1.2 minimum). No plain HTTP in production:
app.use((req, res, next) => {
if (req.headers['x-forwarded-proto'] !== 'https' && process.env.NODE_ENV === 'production') {
return res.status(403).json({ error: 'HTTPS required' });
}
next();
});
const helmet = require('helmet');
app.use(helmet({
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
}));
Helmet sets Strict-Transport-Security, X-Content-Type-Options, and X-Frame-Options automatically.
2026 Best Practices: OIDC, SHA-Pinned Actions, Least Privilege
- OIDC over static credentials: Use OpenID Connect for authentication instead of long-lived API keys where possible. OIDC tokens expire and can be refreshed securely.
- SHA-pinned GitHub Actions: If your CI/CD uses GitHub Actions, pin actions by commit SHA (
uses: actions/checkout@a81bbbf8298c0fa03ea29cdc473d45769f953675) instead of tags. Tags can be force-pushed; SHAs can't. - Least-privilege permissions: API keys and service accounts should have the minimum permissions needed. An API key for reading logs shouldn't have write access to the database.
Handling Rate Limit Errors Gracefully
Return structured 429 responses with retry timing:
app.use((err, req, res, next) => {
if (err.status === 429) {
return res.status(429).json({
error: 'Too Many Requests',
retryAfter: req.rateLimit.resetTime,
limit: req.rateLimit.limit,
remaining: req.rateLimit.remaining,
});
}
next(err);
});
Clients should implement exponential backoff:
async function fetchWithRetry(url, options = {}, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
const response = await fetch(url, options);
if (response.ok) return response;
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
const delay = retryAfter ? parseInt(retryAfter) * 1000 : Math.pow(2, i) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw new Error(`Request failed: ${response.status}`);
}
throw new Error('Max retries exceeded');
}
For end users, translate 429s into actionable messages: "You're making requests too quickly. Please wait 2 minutes and try again."
Common Rate Limiting Mistakes and How to Avoid Them
Mistake #1: Rate Limiting Before Authentication
If you rate limit by IP before authenticating, attackers can exhaust the IP limit and block all users behind that IP (entire office behind corporate NAT).
Fix: Apply strict per-IP limits only to unauthenticated endpoints. For authenticated endpoints, rate limit by user ID after verifying the token:
// WRONG: Rate limit by IP for authenticated endpoints
app.use('/api/', ipRateLimiter); // Blocks entire office if one user hits limit
app.use('/api/', requireAuth);
// RIGHT: Authenticate first, then rate limit by user
app.use('/api/', requireAuth);
app.use('/api/', userRateLimiter); // Per-user limits
Mistake #2: Same Limits for All Endpoints
A health check endpoint can handle 1,000 requests/second. A data export endpoint that generates a 50MB CSV should be limited to 1 request per minute. Apply endpoint-specific limits:
app.use('/api/health', rateLimit({ max: 10000, windowMs: 60000 })); // 10k/min
app.use('/api/export', rateLimit({ max: 1, windowMs: 60000 })); // 1/min
Mistake #3: In-Memory Counters in Distributed Systems
If you run multiple API servers and rate limit with in-process memory, each server tracks limits independently. A client can send 100 requests to server A and 100 to server B, bypassing your "100 requests total" limit.
Fix: Use Redis or another shared data store for rate limit counters in distributed systems.
Monitoring and Alerting for API Security
Rate limiting prevents attacks, but monitoring tells you when attacks are happening.
Track These Metrics
const prometheus = require('prom-client');
const rateLimitHitsCounter = new prometheus.Counter({
name: 'api_rate_limit_hits_total',
help: 'Requests blocked by rate limiting',
labelNames: ['endpoint', 'client_type'],
});
app.use((req, res, next) => {
res.on('finish', () => {
if (res.statusCode === 429) {
rateLimitHitsCounter.inc({
endpoint: req.path,
client_type: req.user ? 'authenticated' : 'public',
});
}
});
next();
});
Watch for:
- 429 rate >10% of traffic — possible attack in progress
- 401 spike >5% — credential stuffing attempt
- Persistent offenders — track which IPs/users hit limits most often
Log for Investigation
app.use((req, res, next) => {
res.on('finish', () => {
if (res.statusCode === 429) {
console.log(JSON.stringify({
event: 'rate_limit_exceeded',
ip: req.ip,
userId: req.user?.id,
endpoint: req.path,
timestamp: new Date().toISOString(),
}));
}
});
next();
});
Pipe logs to a centralized system (CloudWatch, DataDog, Elasticsearch) for cross-server queries. Alert when API keys are used from multiple IPs in short time spans (possible theft) or when usage exceeds normal patterns by >5x.
Rate limiting is infrastructure, not a feature. It's the unglamorous foundation that keeps your API online when someone decides to test your defenses at 3 AM. I've seen it stop credential-stuffing attacks cold. I've watched DDoS attempts fizzle out against token buckets. And I've never again woken up to a four-figure cloud bill from uncontrolled scraping.
The code examples in this guide run in production. The attack scenarios are real. The configuration recommendations come from incidents I've responded to, prevented, or caused (that AWS bill taught me well). Implement rate limiting before you need it. Layer it with authentication, authorization, and input validation. Monitor it obsessively. And when your on-call engineer thanks you for stopping an attack before it became an outage, you'll know the infrastructure was worth it.
Tested environment: Node.js 20 LTS, Express 4.18, Redis 7.2, Ubuntu 22.04