API Security in Practice: Rate Limiting, API Keys, and IP Restrictions
Prologue: What Happens When You Make an API Public
What happens when you make an API public without rate limiting? Stories of APIs getting hammered with tens of thousands of requests within hours are surprisingly common. A single IP sending hundreds of requests per second to crawl your data, or a buggy client stuck in an infinite loop—these things happen more often than you'd think. Cloud bills skyrocketing overnight is a story you'll hear again and again.
These cases make one thing clear: making an API public means opening your server's door to the entire world. Rate limiting is essential.
Aha! The Water Faucet Metaphor
There's a helpful analogy for understanding rate limiting.
Think of an API as a water faucet. Users line up to fill their bottles. But what if someone brings a massive tank and keeps filling it endlessly? Everyone else can't get water. The water bill (AWS costs) explodes.
Three solutions emerged:
- Fixed Window: "You can only fill 100 times per minute." Reset the counter every minute by the clock.
- Sliding Window: "I check if you filled more than 100 times in the last exact 60 seconds." More precise.
- Token Bucket: "You have 100 tokens in your bucket. Each request costs 1 token, and tokens refill at 10 per second." Allows burst traffic while limiting average rate.
Initially overwhelming, but this metaphor makes it clear: "Who can use the resources, how much, and for how long?"
Deep Dive: Implementing Rate Limiting in Production
1. Comparing Rate Limiting Algorithms
Here's a comparison of four main algorithms and their trade-offs.
Fixed Window Counter
Simplest approach. Divide time into fixed intervals and count requests in each.
// Fixed Window with Redis
import { Redis } from '@upstash/redis'
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
})
async function fixedWindowRateLimit(
identifier: string,
limit: number,
windowSeconds: number
): Promise<{ allowed: boolean; remaining: number }> {
const now = Date.now()
const window = Math.floor(now / (windowSeconds * 1000))
const key = `ratelimit:${identifier}:${window}`
const count = await redis.incr(key)
if (count === 1) {
await redis.expire(key, windowSeconds)
}
const allowed = count <= limit
const remaining = Math.max(0, limit - count)
return { allowed, remaining }
}
// Usage: limit to 60 requests per minute per user
const { allowed, remaining } = await fixedWindowRateLimit(
`user:${userId}`,
60,
60
)
if (!allowed) {
return new Response('Too Many Requests', {
status: 429,
headers: {
'X-RateLimit-Limit': '60',
'X-RateLimit-Remaining': '0',
'Retry-After': '60'
}
})
}
The problem is edge cases. If you make 60 requests at 00:59 and 60 more at 01:01, that's 120 requests in 2 minutes, but the system won't block it because the counter resets when the window changes.
Sliding Window Counter
Solves the Fixed Window problem. Counts requests in the last exact N seconds.
// Sliding Window with Redis Sorted Set
async function slidingWindowRateLimit(
identifier: string,
limit: number,
windowSeconds: number
): Promise<{ allowed: boolean; remaining: number; reset: number }> {
const now = Date.now()
const windowStart = now - (windowSeconds * 1000)
const key = `ratelimit:sliding:${identifier}`
// 1. Remove old requests
await redis.zremrangebyscore(key, 0, windowStart)
// 2. Count requests in current window
const count = await redis.zcard(key)
if (count < limit) {
// 3. Add new request
await redis.zadd(key, { score: now, member: `${now}-${Math.random()}` })
await redis.expire(key, windowSeconds)
return {
allowed: true,
remaining: limit - count - 1,
reset: now + (windowSeconds * 1000)
}
}
return {
allowed: false,
remaining: 0,
reset: now + (windowSeconds * 1000)
}
}
More accurate, but requires Redis Sorted Sets which increase memory usage. Can get expensive with high traffic.
Token Bucket (The Most Common Choice in Production)
Bucket holds tokens, each request consumes one. Tokens refill at a constant rate.
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
})
// Token Bucket configuration
const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.tokenBucket(10, '10s', 3), // 10 tokens per 10s, burst of 3
analytics: true,
prefix: '@upstash/ratelimit',
})
// Use in API route
export async function POST(request: Request) {
const ip = request.headers.get('x-forwarded-for') ?? 'anonymous'
const { success, limit, remaining, reset } = await ratelimit.limit(ip)
if (!success) {
return new Response('Too Many Requests', {
status: 429,
headers: {
'X-RateLimit-Limit': limit.toString(),
'X-RateLimit-Remaining': remaining.toString(),
'X-RateLimit-Reset': reset.toString(),
},
})
}
return new Response('OK', {
headers: {
'X-RateLimit-Limit': limit.toString(),
'X-RateLimit-Remaining': remaining.toString(),
'X-RateLimit-Reset': reset.toString(),
},
})
}
Why Token Bucket is preferred: allows bursts while limiting average rate. Users can occasionally send many requests, but sustained high volume gets blocked.
2. API Key Management: Generation, Validation, Rotation
Rate limiting alone isn't enough. You need to know "who" is using the API. That's where API keys come in.
Generating and Storing API Keys
import crypto from 'crypto'
import { hash, verify } from '@node-rs/argon2'
// Generate API key (show to user only once)
export async function generateApiKey(userId: string) {
const apiKey = `sk_${crypto.randomBytes(32).toString('base64url')}`
// Hash for database storage (never store plaintext)
const hashedKey = await hash(apiKey, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
})
await db.apiKey.create({
data: {
userId,
keyHash: hashedKey,
keyPrefix: apiKey.substring(0, 10), // for identification only
scopes: ['read', 'write'],
createdAt: new Date(),
lastUsedAt: null,
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year
},
})
return apiKey // show only once
}
// Verify API key (in middleware)
export async function verifyApiKey(apiKey: string) {
if (!apiKey.startsWith('sk_')) {
return null
}
const prefix = apiKey.substring(0, 10)
const storedKey = await db.apiKey.findFirst({
where: { keyPrefix: prefix },
include: { user: true },
})
if (!storedKey) {
return null
}
const isValid = await verify(storedKey.keyHash, apiKey)
if (!isValid || (storedKey.expiresAt && storedKey.expiresAt < new Date())) {
return null
}
// Update last used (async, fire-and-forget)
db.apiKey.update({
where: { id: storedKey.id },
data: { lastUsedAt: new Date() },
}).catch(() => {})
return {
userId: storedKey.userId,
scopes: storedKey.scopes,
user: storedKey.user,
}
}
Key principle: never store the original key. Like passwords, only store hashes. Database breach won't expose keys.
3. IP Restrictions: Allowlist and Blocklist
API keys help, but sometimes you need to restrict access to specific IPs.
export async function checkIpAllowlist(request: Request, userId: string) {
const ip = request.headers.get('x-forwarded-for')?.split(',')[0] ??
request.headers.get('x-real-ip') ??
'unknown'
const allowlist = await db.ipAllowlist.findMany({
where: { userId },
})
if (allowlist.length > 0) {
const isAllowed = allowlist.some(entry => {
if (entry.cidr) {
return isIpInCidr(ip, entry.cidr)
}
return entry.ip === ip
})
if (!isAllowed) {
return { allowed: false, reason: 'IP not in allowlist' }
}
}
const isBlocked = await redis.sismember('ip:blocklist', ip)
if (isBlocked) {
return { allowed: false, reason: 'IP blocked' }
}
return { allowed: true, ip }
}
4. Monitoring and Alerting
The final piece is monitoring. You need to detect suspicious patterns quickly.
export async function detectSuspiciousActivity(userId: string, ip: string) {
const now = Date.now()
const key = `suspicious:${userId}:${ip}`
const failCount = await redis.incr(`${key}:fail`)
await redis.expire(`${key}:fail`, 60)
if (failCount >= 5) {
await sendAlert({
type: 'suspicious_activity',
userId,
ip,
message: `${failCount} failed attempts in 1 minute`,
})
await redis.setex(`ip:blocked:${ip}`, 3600, '1') // block for 1 hour
}
}
Summary: API Security is Built in Layers
Can't solve API security all at once. Build it layer by layer.
Network Level: Cloudflare WAF for DDoS and bot protection
Application Level: Rate limiting, API keys, IP restrictions, CORS
Data Level: Request validation, input sanitization, output filtering
Monitoring Level: Anomaly detection, real-time alerts, log analysis
And most importantly, use HTTP headers properly:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1708041600
Retry-After: 60
When users know when they can retry, unnecessary retries decrease.
Making an API public means opening your server to the world. From that moment, security isn't optional—it's essential. Rate limiting is the first step.