Rate limiting i security dla API. Bez czego produkcja się rozjeżdża po pierwszym miesiącu
W 2023 klient zadzwonił do mnie w niedzielę wieczorem. “API padnie co godzinę, klienci narzekają”. Otworzyłem monitoring. Jeden użytkownik generował 10 000 requestów na sekundę. Bot scraping. Bez rate limitów cały serwer padał pod jednym atakującym.
Pierwsza rzecz, którą naprawiłem, to rate limiting. Godzina pracy. Problem znikł. Klient stracił jeden dzień revenue, ale nauka trwała mnie do dziś.
Ten post to konkretne strategie rate limiting plus security best practices dla API w produkcji. Rzeczy, których nie ma w tutorialach, ale bez których produkcja się rozjeżdża.
Co to jest rate limiting
Rate limiting to ograniczenie, ile requestów per user/IP/API key może być wykonanych w danym oknie czasowym.
Przykłady:
- 100 requestów per minute per user
- 1000 requestów per hour per API key
- 10 000 requestów per day per IP
Bez rate limitingu:
- Bot scraper pobiera całą Twoją bazę
- Bad actor wywołuje 10k razy costly endpoint, koszt idzie do Ciebie
- Bug w kliencie generuje infinite loop, DDoS’ując Twój serwer
- User “przypadkowo” DDoS’uje przez badly written frontend code
Rate limiting to podstawa. Nie luksus.
Trzy strategies
Token bucket (najczęstsze):
- User ma “bucket” z tokens
- Każdy request kosztuje 1 token
- Bucket regeneruje N tokens per sekundę
- Pusty bucket = 429 Too Many Requests
Zalety: allows burst usage (user może wysłać 100 requestów naraz, potem regenerate). Dobre UX.
Wady: implementation nieco complex.
Sliding window:
- Liczysz requesty w ostatnich X sekundach
- Ponad limit = 429
Zalety: dokładne (nie ma “bucket refill” edge cases). Fair distribution.
Wady: może być computationally expensive dla big volumes.
Fixed window:
- Liczysz requesty w danym oknie (np. każdą minutę)
- Nowe okno = reset counter
Zalety: prosta implementacja.
Wady: burst on window boundary. User może wysłać 100 req w ostatniej sekundzie minuty i 100 req w pierwszej sekundzie następnej minuty. Effective 200 req w 2 sekundy.
Dla większości projektów: token bucket. Balance between UX i complexity.
Implementacja z Redis (Node.js)
Standard implementation:
import Redis from 'ioredis'
const redis = new Redis()
interface RateLimitConfig {
key: string // np. `user:${userId}`
limit: number // ile tokens max
window: number // seconds
}
async function checkRateLimit(config: RateLimitConfig): Promise<{
allowed: boolean
remaining: number
resetAt: number
}> {
const now = Date.now()
const windowStart = now - config.window * 1000
const key = `ratelimit:${config.key}`
await redis.zremrangebyscore(key, 0, windowStart)
const count = await redis.zcard(key)
if (count >= config.limit) {
const oldestScore = await redis.zrange(key, 0, 0, 'WITHSCORES')
const resetAt = parseInt(oldestScore[1]) + config.window * 1000
return { allowed: false, remaining: 0, resetAt }
}
await redis.zadd(key, now, `${now}:${Math.random()}`)
await redis.expire(key, config.window)
return {
allowed: true,
remaining: config.limit - count - 1,
resetAt: now + config.window * 1000,
}
}
Kluczowe:
- Sorted set w Redis: score = timestamp, member = unique ID
- Cleanup old entries przed count
- Atomic operations (Redis is single-threaded)
- Expire klucza: memory cleanup
Middleware pattern
Next.js middleware:
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
const ip = request.headers.get('x-forwarded-for') ?? 'anonymous'
const result = await checkRateLimit({
key: `ip:${ip}`,
limit: 100,
window: 60, // per minute
})
if (!result.allowed) {
return new NextResponse('Too Many Requests', {
status: 429,
headers: {
'X-RateLimit-Limit': '100',
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': String(result.resetAt),
'Retry-After': String(Math.ceil((result.resetAt - Date.now()) / 1000)),
},
})
}
const response = NextResponse.next()
response.headers.set('X-RateLimit-Limit', '100')
response.headers.set('X-RateLimit-Remaining', String(result.remaining))
return response
}
export const config = {
matcher: '/api/:path*',
}
Kluczowe:
- 429 status code: standard dla rate limit exceeded
- Retry-After header: klient wie, kiedy retry
- X-RateLimit-* headers: transparency dla klienta
Tiered rate limiting
Nie każdy user ma taką samą stawkę.
async function getRateLimitForUser(userId: string) {
const user = await db.users.findOne({ where: { id: userId } })
switch (user.tier) {
case 'free':
return { limit: 100, window: 60 }
case 'pro':
return { limit: 1000, window: 60 }
case 'enterprise':
return { limit: 10000, window: 60 }
default:
return { limit: 20, window: 60 } // anonymous
}
}
Free tier restricted, paying customers get more, enterprise practically unlimited.
Per-endpoint limits
Nie wszystkie endpointy są równe.
const ENDPOINT_LIMITS: Record<string, { limit: number; window: number }> = {
'/api/auth/login': { limit: 5, window: 300 }, // 5 login attempts per 5 min
'/api/auth/register': { limit: 3, window: 3600 }, // 3 registrations per hour
'/api/upload': { limit: 10, window: 60 }, // 10 uploads per minute
'/api/search': { limit: 100, window: 60 }, // 100 searches per minute
'/api/*': { limit: 1000, window: 60 }, // default fallback
}
Auth endpoints strict (prevent brute force). Search endpoints generous (user experience).
Distributed rate limiting
Multi-server deployment: rate limits musi być shared.
Bez shared storage: user może hit Twój load balancer, dostać się na serwer A (limit not hit), potem na serwer B (limit not hit). Effective limit 2x zwiększony.
Rozwiązanie: shared Redis. Cały cluster używa jednego Redis dla rate limit state.
Alternatywy:
- Distributed cache (Redis Cluster, ElastiCache)
- In-memory grid (Hazelcast, Ignite)
- Custom solution (Postgres for slower, Redis for faster)
Dla większości projektów: Redis. Simple, fast, battle-tested.
Rate limiting AI endpoints
AI endpointy są expensive. Bardziej restrictive limits.
const AI_ENDPOINT_LIMITS = {
'/api/ai/chat': {
// Free: 10 messages per day
// Pro: 100 messages per day
// Enterprise: 1000 messages per day
perUser: (user) => ({
limit: user.tier === 'enterprise' ? 1000 : user.tier === 'pro' ? 100 : 10,
window: 86400,
}),
},
}
Plus: implement token-based limiting (nie tylko count). User może wysłać 10 requestów, ale każdy wygenerował 4000 tokens = 40k tokens = expensive.
OWASP API Security Top 10
Rate limiting to jedno. Reszta security też matter. OWASP API Security Top 10 to must-know.
1. Broken Object Level Authorization (BOLA):
Endpoint /api/orders/:id zwraca order. Nie sprawdza, czy user może widzieć ten order.
Fix: check w every endpoint.
const order = await db.orders.findOne({ where: { id } })
if (order.userId !== currentUser.id && !currentUser.isAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
2. Broken Authentication:
Weak session management, JWT bez signature, długie session lifetimes.
Fix: strong auth library (NextAuth, Clerk, Auth0), short access tokens, refresh tokens, JWT proper signing.
3. Broken Object Property Level Authorization:
Endpoint updates user. User posyła { role: 'admin' }. Endpoint updates role. Privilege escalation.
Fix: whitelist pól, które user może modify.
const allowedFields = ['name', 'email', 'avatar']
const updates = pick(request.body, allowedFields)
await db.users.update({ where: { id: currentUser.id }, data: updates })
4. Unrestricted Resource Consumption:
Bez rate limits, bez size limits, endpoint może być weaponized.
Fix: rate limits (opisane wyżej), request size limits, timeout limits, worker limits.
5. Broken Function Level Authorization:
Regular user może uderzyć w /api/admin/deleteUser. Powinien nie móc.
Fix: role-based access control (RBAC) middleware.
if (!currentUser.isAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
6. Server-Side Request Forgery (SSRF):
Endpoint fetches URL provided by user. User provides http://internal-api:8080/admin. Endpoint fetches internal API.
Fix: URL whitelisting, allowlist domains, block internal IP ranges.
7. Security Misconfiguration:
Debug mode on production, verbose error messages, unnecessary headers, default credentials.
Fix: production hardening checklist. Envs properly configured.
8. Lack of Protection from Automated Threats:
No CAPTCHA on signup, no bot detection, no anomaly detection.
Fix: CAPTCHA (Cloudflare Turnstile, hCaptcha), bot detection (Cloudflare, DataDome), anomaly detection.
9. Improper Inventory Management:
Old API versions running, deprecated endpoints still active, undocumented internal endpoints.
Fix: API versioning, deprecation schedule, endpoint audit quarterly.
10. Unsafe Consumption of APIs:
Twoja app używa external API. Response nie validated. Bug w external API = bug u Ciebie.
Fix: validate responses from external APIs, timeout limits, fallback logic.
Authentication best practices
Kilka additional points.
Never store passwords plaintext. Argon2 albo bcrypt. Never sha256.
import argon2 from 'argon2'
const hash = await argon2.hash(password)
const valid = await argon2.verify(hash, password)
JWT strategy dla APIs:
- Access token: short-lived (15 min), in memory
- Refresh token: long-lived (7 days), HttpOnly cookie
- Refresh endpoint: dostaje refresh, zwraca nowy access
- Revocation: token blacklist w Redis dla emergency
API Keys dla server-to-server:
- Long random strings
- Stored hashed w bazie
- Rotatable easily
- Scoped (limited permissions per key)
- Auditable (log usage)
Pisałem szerzej o API design. Security jest podzbiorem tego.
Encryption in transit i at rest
In transit: HTTPS obowiązkowo. Let’s Encrypt free. Vercel/Netlify/Cloudflare automatycznie.
At rest: sensitive data (PII, credit cards, medical) encrypted w bazie.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- Encrypt on insert
INSERT INTO users (email, encrypted_ssn)
VALUES ('[email protected]', pgp_sym_encrypt('123-45-6789', 'my-encryption-key'));
-- Decrypt on select
SELECT email, pgp_sym_decrypt(encrypted_ssn::bytea, 'my-encryption-key') as ssn
FROM users;
Key management osobny topic (KMS, HashiCorp Vault). But basic PostgreSQL encryption is start.
CORS: często źle konfigurowany
CORS (Cross-Origin Resource Sharing) często słabo rozumiany.
Zły:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Dopuszcza każdą stronę do wywoływania Twojego API z cookies. Bad.
Dobry:
const allowedOrigins = [
'https://myapp.com',
'https://staging.myapp.com',
]
function corsHeaders(origin: string | null) {
if (origin && allowedOrigins.includes(origin)) {
return {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}
}
return {}
}
Whitelist explicit origins. Bez fallback do wildcard.
Logging plus monitoring
Bez logging jesteś ślepy.
Log:
- Every request: IP, user, endpoint, response code, response time
- Failed auth attempts (potential brute force)
- Rate limit hits
- Suspicious patterns (unusual endpoints, unusual timing)
Monitor:
- Request rate per endpoint
- Error rate (4xx, 5xx)
- Response time p50, p95, p99
- Rate limit hits per user
Alert:
- Sudden traffic spike (potential DDoS)
- 5xx error rate > threshold
- Response time p95 > threshold
- Failed auth attempts > threshold per IP
Tools: Datadog, New Relic, Sentry, Grafana. Wybierz jedno.
Input validation
Never trust user input.
Validation:
import { z } from 'zod'
const CreateOrderSchema = z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive().max(1000),
address: z.string().min(1).max(500),
})
// In endpoint
const parsed = CreateOrderSchema.safeParse(request.body)
if (!parsed.success) {
return NextResponse.json(
{ error: 'Invalid input', details: parsed.error },
{ status: 400 }
)
}
const order = await createOrder(parsed.data)
Sanitization (dla user-generated content):
- HTML: DOMPurify (remove script tags, malicious HTML)
- SQL: prepared statements (never string interpolation)
- Shell commands: escape properly (or avoid altogether)
SQL injection prevention
Never string interpolation w SQL:
Złe:
const query = `SELECT * FROM users WHERE email = '${email}'`
await db.query(query)
User email = '; DROP TABLE users; --. Boom, table dropped.
Dobre:
const result = await db.query(
'SELECT * FROM users WHERE email = $1',
[email]
)
Parameterized query. Escape done by DB driver.
ORM (Prisma, Drizzle) robi to za Ciebie. Ale surowe SQL - always parametrize.
Częste błędy
Rate limits tylko na endpoint level. User może hit różne endpointy tego samego typu. Add global user limit.
Rate limits per IP tylko. Bad actor uses VPN, rotates IP. Add user-level limits też.
Ignoring 429 klient-side. User dostaje 429, frontend retry natychmiast. Loop. Exponential backoff.
No rate limit headers. Klient nie wie limits, guess’uje. Include headers zawsze.
Brak logging security events. Ktoś attackuje, Ty nie wiesz. Log failed auth, unusual patterns.
CORS wildcard w produkcji. * dla Access-Control-Allow-Origin. Explicit whitelist.
Weak passwords allowed. User: “123”. OK. Add password requirements.
Sekrety w kodzie. API keys w Git repository. Rotate, use env vars, use secret manager.
Nie updated dependencies. Known vulnerabilities. npm audit, Dependabot, Snyk.
Co czytać dalej
- OWASP API Security Top 10: obowiązkowa lektura.
- “The Web Application Hacker’s Handbook”: klasyk, gruby, warty.
- PortSwigger Web Security Academy: free online, hands-on labs.
Nie kupuj kursów “Hacking dla programistów”. Practice on Hack The Box, TryHackMe.
Od czego zacząć
Jeśli Twoje API nie ma rate limitingu ani security review:
- Add basic rate limiting dla wszystkich API endpoints. Redis backend.
- Add per-endpoint limits dla auth i AI endpoints (strict).
- Review OWASP Top 10 checklist. Naprawić każdy issue.
- Add security headers (HSTS, CSP, X-Frame-Options).
- Add logging + monitoring. Alerts dla anomalii.
- Add input validation (Zod, Yup, Joi) na każdym endpoint.
- Test with attack tools (OWASP ZAP, Burp Suite).
Security nie jest jednorazowa zadaniem. To continuous discipline. Nowe threats, nowe vulnerabilities, ciągła praca.
Pisałem o API design jako sąsiednim topiku. Dobry API design + solid security = solidne foundation dla produkcji.
Programiści bez security awareness budują aplikacje, które padną na incydencie w 6 miesięcy. Programiści z security mindset budują aplikacje, które działają przez lata. Wybieraj świadomie, od pierwszego commita.