Rate Limiting in Node.js schützt deine API vor Brute-Force-Angriffen, DDoS-Wellen und automatisierten Scrapers. Wer keinen Anfrage-Begrenzer einsetzt, riskiert Serverausfälle, gestohlene Zugangsdaten und hohe Cloud-Rechnungen. Dieses Tutorial zeigt dir in 12 konkreten Schritten, wie du mit express-rate-limit 8.5.2 und rate-limiter-flexible 11.2.0 einen produktionsreifen Schutz aufbaust. Arbeitszeit: rund 35 Minuten.
Was ist Rate Limiting und warum brauchst du es 2026?
Rate Limiting begrenzt, wie viele Anfragen ein Client (identifiziert über IP-Adresse, API-Key oder User-ID) in einem definierten Zeitfenster an deinen Server schicken darf. Überschreitet er das Limit, antwortet der Server mit dem HTTP-Statuscode 429 Too Many Requests.
Der DACH-Raum verzeichnet 2026 wöchentlich über 2.122 Cyberangriffe allein in Österreich. Brute-Force-Angriffe auf Login-Endpunkte gehören laut CERT.at zu den häufigsten Angriffsmustern. Ohne Rate Limiting kann ein Angreifer in einer Minute Tausende Passwort-Kombinationen durchprobieren. Das OWASP API Security Top 10 (2023) listet unter API4:2023 “Unrestricted Resource Consumption” explizit das Fehlen von Rate Limiting als kritische Schwachstelle.
Konkrete Zahlen aus der Praxis: Eine Node.js-API ohne Rate Limiting kann auf typischer Server-Hardware mehrere Tausend Anfragen pro Sekunde verarbeiten. Das bedeutet, ein einzelner Angreifer mit einem einfachen Skript kann in 15 Minuten über 1 Million Passwort-Kombinationen testen. Mit Rate Limiting auf 10 Versuche pro 15 Minuten reduziert sich das auf gerade 10 Kombinationen, was einen Angriff wirtschaftlich sinnlos macht.
| Angriffstyp | Ohne Rate Limiting | Mit Rate Limiting |
|---|---|---|
| Brute-Force-Login | Unbegrenzte Versuche pro Sekunde | Max. 5 Versuche / 15 Min. pro IP |
| API-Scraping | Komplette Datenbank abrufbar | 100 Anfragen / 15 Min. geblockt |
| DDoS-Welle | Serverausfall in Sekunden | Verkehr gedrosselt, Server stabil |
| Credential Stuffing | 1.000 Accounts/Min. prüfbar | Automatisch nach 10 Versuchen gesperrt |
| Kostenexplosion (Cloud) | Unbegrenzte Serverkosten | Anfragen gedeckelt, Kosten kontrolliert |
| E-Mail-Flooding | Unbegrenzte Reset-Mails versendbar | Max. 3 Reset-Mails / Stunde pro Konto |
Die drei Rate-Limiting-Algorithmen im Vergleich
Bevor du Code schreibst, musst du den richtigen Algorithmus wählen. Die Wahl beeinflusst Speicherverbrauch, Genauigkeit und Burst-Toleranz deiner API. Kein Algorithmus ist universell optimal; die Anforderungen deiner Anwendung bestimmen die richtige Wahl.
Fixed Window (Festes Zeitfenster)
Das einfachste Modell: Innerhalb eines festen Fensters (z.B. jede Minute von 00 bis 59 Sekunden) darf ein Client maximal N Anfragen stellen. Der Zähler wird am Fensterrand zurückgesetzt. Das Problem: An der Grenze zweier Fenster kann ein Client kurzzeitig die doppelte Anzahl Anfragen senden, ohne das Limit zu verletzen. Geeignet für unkritische Endpunkte und geringe Last. Wenig Speicherverbrauch, einfache Implementierung.
Sliding Window (Gleitendes Zeitfenster)
Statt eines starren Resets wird der Zähler kontinuierlich gegen ein rückwärtslaufendes Zeitfenster gemessen. Macht ein Client um 12:00:30 Uhr seine 100. Anfrage, zählt das Fenster von 11:59:30 bis 12:00:30. Grenzeffekte entfallen. Speicherverbrauch ist höher, weil Zeitstempel einzelner Anfragen gespeichert werden. Die Sliding Counter-Variante approximiert das mit weniger Speicher bei guter Genauigkeit.
Token Bucket (Token-Eimer)
Stell dir einen Eimer vor, der sich mit einer festen Rate mit Tokens füllt. Jede Anfrage kostet einen Token. Ist der Eimer leer, wird die Anfrage abgelehnt. Füllt er sich schneller als Anfragen kommen, bildet sich ein Vorrat, der kurze Traffic-Bursts erlaubt. Das ist das bevorzugte Modell für produktive APIs, die organischen Nutzer-Traffic mit gelegentlichen Spitzen bedienen. Laut einem aktuellen Node.js-Implementierungsguide bietet Token Bucket die beste Balance aus Burst-Toleranz und Speichereffizienz.
| Algorithmus | Speicher | Genauigkeit | Burst-Toleranz | Empfohlen für |
|---|---|---|---|---|
| Fixed Window | Sehr gering | Gering | Gering (2x an Grenzen) | Interne Tools, geringe Last |
| Sliding Window Log | Hoch | Sehr hoch | Präzise | Finanzielle APIs, Compliance |
| Sliding Counter | Gering | Hoch | Glatt | User-facing APIs |
| Token Bucket | Gering | Mittel | Hoch (kontrolliert) | Public APIs, OAuth-Endpunkte |
| Leaky Bucket | Gering | Mittel | Keine (gleichmäßig) | Streaming, Warteschlangen |
Voraussetzungen
Bevor du startest, stelle sicher, dass folgende Software installiert ist und die angegebenen Versionen oder neuere verfügbar sind:
- Node.js 22.x LTS oder höher (Prüfen:
node --version) - npm 10.x oder höher (Prüfen:
npm --version) - Express.js 4.21.x oder 5.x
- Redis 7.x (optional, für verteilte Systeme; Prüfen:
redis-server --version) - Ein Terminalprogramm (Terminal, PowerShell oder WSL2 unter Windows)
- Grundkenntnisse in JavaScript und REST-APIs
Falls du Node.js noch nicht installiert hast, lade die LTS-Version von nodejs.org herunter. Redis kannst du unter Ubuntu mit sudo apt install redis-server installieren, unter macOS mit brew install redis. Für Windows empfiehlt sich Redis unter WSL2.
Schritt 1: Projekt aufsetzen
Erstelle ein neues Verzeichnis und initialisiere ein Node.js-Projekt. Wir bauen eine einfache Express-API mit mehreren Routen, die wir schrittweise absichern. Diese Basis dient als vollständiges Demoprojekt, das du am Ende des Tutorials direkt in Produktion einsetzen kannst.
mkdir nodejs-rate-limit-demo
cd nodejs-rate-limit-demo
npm init -y
npm install express
Erstelle eine Basisdatei index.js mit folgendem Inhalt:
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;
app.use(express.json());
// Öffentliche Route (kein Auth erforderlich)
app.get('/api/produkte', (req, res) => {
res.json({ produkte: ['Laptop', 'Maus', 'Tastatur'] });
});
// Login-Endpunkt (besonders schützenswert)
app.post('/api/login', (req, res) => {
const { email, passwort } = req.body;
if (email === '[email protected]' && passwort === 'geheim123') {
res.json({ token: 'jwt-token-hier' });
} else {
res.status(401).json({ fehler: 'Ungültige Zugangsdaten' });
}
});
// Passwort zurücksetzen
app.post('/api/passwort-reset', (req, res) => {
res.json({ nachricht: 'Reset-E-Mail wurde gesendet' });
});
// Für Tests: App exportieren, nicht direkt starten
if (require.main === module) {
app.listen(PORT, () => {
console.log(`Server läuft auf http://localhost:${PORT}`);
});
}
module.exports = app;
Starte den Server mit node index.js und überprüfe mit curl http://localhost:3000/api/produkte, ob er antwortet. Erwartet wird: {"produkte":["Laptop","Maus","Tastatur"]}.
Schritt 2: express-rate-limit installieren
express-rate-limit ist das meistgenutzte Rate-Limiting-Middleware-Paket für Express.js. Version 8.5.2 (Stand Juni 2026) unterstützt Node.js 16 und höher und setzt standardmäßig auf die RateLimit-*-Header nach IETF Draft für HTTP Rate Limit Headers. Das Paket hat weit über 10 Millionen wöchentliche Downloads auf npm und ist in fast jedem produktiven Node.js-Sicherheits-Stack zu finden.
npm install [email protected]
Überprüfe die Installation:
npm list express-rate-limit
# Erwartet:
# [email protected]
# └── [email protected]
Für TypeScript-Projekte sind die Typdefinitionen direkt im Paket enthalten, kein separates @types/-Paket nötig.
Schritt 3: Globales Rate Limiting konfigurieren
Das globale Limit greift für alle Routen deiner API. Es ist die erste Verteidigungslinie gegen allgemeines API-Flooding und verhindert, dass ein einzelner Client die gesamte Serverkapazität monopolisiert. Füge folgenden Code in index.js ein, direkt nach den require-Anweisungen und der app-Initialisierung:
const rateLimit = require('express-rate-limit');
// Globales Limit: 100 Anfragen pro 15 Minuten pro IP
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 Minuten in Millisekunden
max: 100, // Maximale Anfragen pro Fenster
standardHeaders: 'draft-8', // RateLimit-*-Header nach IETF-Draft
legacyHeaders: false, // X-RateLimit-*-Header deaktivieren
message: {
fehler: 'Zu viele Anfragen. Bitte warte 15 Minuten.',
retryAfter: '15 Minuten'
}
});
// Globales Limit auf alle Routen anwenden
app.use(globalLimiter);
Teste das Limit mit einem einfachen Bash-Skript, das 105 Anfragen in schneller Folge sendet:
for i in $(seq 1 105); do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/api/produkte)
echo "Anfrage $i: HTTP $STATUS"
done
Ab Anfrage 101 erscheint folgende Ausgabe:
Anfrage 98: HTTP 200
Anfrage 99: HTTP 200
Anfrage 100: HTTP 200
Anfrage 101: HTTP 429
Anfrage 102: HTTP 429
Anfrage 103: HTTP 429
Die Antwort bei HTTP 429 enthält automatisch den Retry-After-Header, der dem Client mitteilt, wann er es wieder versuchen darf. Gut implementierte API-Clients lesen diesen Header aus und warten entsprechend, statt sofort erneut zu versuchen.
Schritt 4: Routen-spezifisches Rate Limiting
Nicht alle Endpunkte brauchen dasselbe Limit. Ein Login-Formular sollte viel strenger begrenzt werden als ein öffentlicher Produktkatalog. Best Practices für 2026 empfehlen, Auth-Endpunkte 10-mal strenger zu begrenzen als reguläre API-Endpunkte. Erstelle separate Limiter-Instanzen für verschiedene Sensibilitätsstufen:
// Striktes Limit für Auth-Endpunkte: 10 Versuche / 15 Minuten
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: 'draft-8',
legacyHeaders: false,
skipSuccessfulRequests: true, // Erfolgreiche Logins zählen nicht mit
message: {
fehler: 'Zu viele Login-Versuche. Warte 15 Minuten.',
code: 'AUTH_RATE_LIMIT'
}
});
// Moderates Limit für schreibende Operationen: 30 / 10 Minuten
const writeLimiter = rateLimit({
windowMs: 10 * 60 * 1000,
max: 30,
standardHeaders: 'draft-8',
legacyHeaders: false
});
// Auth-Routen mit striktem Limiter absichern
app.post('/api/login', authLimiter, (req, res) => {
const { email, passwort } = req.body;
if (email === '[email protected]' && passwort === 'geheim123') {
res.json({ token: 'jwt-token-hier' });
} else {
res.status(401).json({ fehler: 'Ungültige Zugangsdaten' });
}
});
app.post('/api/passwort-reset', authLimiter, (req, res) => {
res.json({ nachricht: 'Reset-E-Mail wurde gesendet' });
});
// Schreibende Routen mit moderatem Limiter
app.post('/api/bewertung', writeLimiter, (req, res) => {
res.json({ nachricht: 'Bewertung gespeichert' });
});
Die Option skipSuccessfulRequests: true ist besonders für Login-Endpunkte sinnvoll: Erfolgreiche Anmeldungen (HTTP 2xx) zählen nicht gegen das Limit. So werden echte Nutzer nicht ausgesperrt, während Brute-Force-Angreifer schnell das Limit erreichen, da deren Versuche fast immer scheitern.
Schritt 5: IP-Erkennung hinter einem Reverse Proxy korrekt konfigurieren
Einer der häufigsten Fehler beim Rate Limiting: Wenn deine Node.js-App hinter nginx, einem AWS Load Balancer oder Cloudflare sitzt, sieht req.ip immer die IP des Proxys, nicht des echten Clients. Das Rate Limit gilt dann für alle Nutzer gemeinsam, und das System sperrt sich buchstäblich selbst.
Konfiguriere Express so, dass es dem X-Forwarded-For-Header vertraut:
// Direkt nach app = express():
// Anzahl der vertrauenswürdigen Proxy-Hops setzen
// 1 = nginx auf demselben Server
// 2 = nginx + AWS ELB
// true = allen Proxys vertrauen (nur in geschlossenen Netzwerken!)
app.set('trust proxy', 1);
// Optional: Eigene IP-Extraktion für mehrschichtige Proxys
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: 'draft-8',
legacyHeaders: false,
// Authentifizierte User per ID begrenzen, Fallback auf IP
keyGenerator: (req) => {
return req.user?.id || req.ip;
}
});
Überprüfe nach der Konfiguration, welche IP dein Server erkennt. Diese Debug-Route nur in der Entwicklungsumgebung aktivieren, niemals in Produktion:
if (process.env.NODE_ENV !== 'production') {
app.get('/api/debug-ip', (req, res) => {
res.json({
ip: req.ip,
ips: req.ips,
xForwardedFor: req.headers['x-forwarded-for']
});
});
}
Schritt 6: Redis-Integration für verteilte Systeme
Wenn du mehrere Node.js-Instanzen betreibst (z.B. mit PM2 im Cluster-Modus oder in Kubernetes), speichert jede Instanz ihren eigenen Zähler im Arbeitsspeicher. Ein Client, der Anfragen über 4 Instanzen verteilt, kann effektiv das 4-fache des Limits senden, ohne gesperrt zu werden. Die Lösung: ein gemeinsamer Redis-Speicher, auf den alle Instanzen zugreifen.
npm install @express-rate-limit/redis ioredis
Konfiguriere den Redis-Store in index.js:
const rateLimit = require('express-rate-limit');
const { RedisStore } = require('@express-rate-limit/redis');
const Redis = require('ioredis');
// Redis-Verbindung einmalig aufbauen (Singleton-Pattern)
const redisClient = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASSWORD || undefined,
enableReadyCheck: true,
maxRetriesPerRequest: 3
});
redisClient.on('error', (err) => {
console.error('Redis-Verbindungsfehler:', err.message);
// Monitoring/Alerting hier einbinden
});
redisClient.on('connect', () => {
console.log('Redis verbunden');
});
// Rate Limiter mit Redis-Backend
const distributedLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: 'draft-8',
legacyHeaders: false,
store: new RedisStore({
sendCommand: (...args) => redisClient.call(...args),
prefix: 'rl:' // Schlüssel-Präfix verhindert Kollisionen mit anderen Redis-Keys
}),
keyGenerator: (req) => req.user?.id || req.ip
});
app.use(distributedLimiter);
Überprüfe, ob Redis die Zähler korrekt speichert. Dazu in einem neuen Terminal:
# Terminal 1: Redis-Befehle in Echtzeit beobachten
redis-cli monitor
# Terminal 2: Anfrage senden
curl http://localhost:3000/api/produkte
# Redis-Monitor zeigt jetzt z.B.:
# 1717600000.123 [0 127.0.0.1:52910] "SET" "rl:127.0.0.1" "1" "PX" "900000" "NX"
# Bei weiteren Anfragen:
# "INCR" "rl:127.0.0.1"
Die Redis-Keys laufen automatisch nach Ablauf des Zeitfensters ab (PX setzt die TTL in Millisekunden). Du musst dich nicht um das Aufräumen alter Zähler kümmern.
Schritt 7: rate-limiter-flexible für erweiterte Algorithmen
Für komplexere Anforderungen, etwa Token-Bucket-Algorithmus, progressive Blockierung oder Kombinationen aus User-ID und IP, empfiehlt sich rate-limiter-flexible 11.2.0. Das Paket unterstützt Redis, MongoDB, Postgres, MySQL und In-Memory-Speicher mit identischer API und ist eine der flexibelsten Rate-Limiting-Lösungen für Node.js.
npm install rate-limiter-flexible
Implementiere einen kombinierten Limiter, der sowohl IP als auch E-Mail-Adresse berücksichtigt. Das verhindert verteilte Angriffe, bei denen ein Angreifer seine Anfragen über viele verschiedene IP-Adressen streut:
const { RateLimiterRedis } = require('rate-limiter-flexible');
const Redis = require('ioredis');
const redisClient = new Redis({ host: 'localhost', port: 6379 });
// Kombinierter Limiter: IP + E-Mail als Schlüssel
const loginLimiter = new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'login_limit',
points: 10, // 10 Versuche erlaubt
duration: 900, // pro 15 Minuten (in Sekunden)
blockDuration: 900 // 15 Min. sperren nach Überschreitung
});
// Separater Limiter nur für IP (verhindert IP-übergreifende Angriffe auf ein Konto)
const loginLimiterByEmail = new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'login_email',
points: 5,
duration: 900,
blockDuration: 900
});
app.post('/api/login-sicher', async (req, res) => {
const { email, passwort } = req.body;
try {
// Beide Limits prüfen: IP-basiert und E-Mail-basiert
const [ipLimit, emailLimit] = await Promise.all([
loginLimiter.consume(req.ip),
loginLimiterByEmail.consume(email.toLowerCase())
]);
// Anmeldelogik
if (email === '[email protected]' && passwort === 'geheim123') {
// Nach erfolgreichem Login Zähler zurücksetzen
await Promise.all([
loginLimiter.delete(req.ip),
loginLimiterByEmail.delete(email.toLowerCase())
]);
res.json({ token: 'jwt-token-platzhalter' });
} else {
res.status(401).json({
fehler: 'Ungültige Zugangsdaten',
versuche_ip: ipLimit.remainingPoints,
versuche_email: emailLimit.remainingPoints
});
}
} catch (err) {
// Rate Limit überschritten
const retryAfter = Math.round(err.msBeforeNext / 1000) || 900;
res.set('Retry-After', retryAfter);
res.status(429).json({
fehler: 'Zu viele Anmeldeversuche.',
retryAfterSekunden: retryAfter
});
}
});
Schritt 8: Dynamisches Rate Limiting nach User-Rolle
Enterprise-APIs unterscheiden oft zwischen Plan-Stufen: Free-Tier-Nutzer erhalten 100 Anfragen pro Stunde, Premium-Nutzer 1.000, interne Services sind unbegrenzt. Statt mehrere Limiter-Instanzen manuell zu verwalten, ist eine dynamische Middleware sauberer und leichter wartbar.
Für diesen Schritt musst du sicherstellen, dass eine JWT-Middleware die Nutzerinformationen in req.user schreibt, bevor der dynamische Limiter greift. Wie du eine digitale Signatur und JWT in Node.js implementierst, erklärt unser separates Tutorial.
// Limiter nach Plan-Stufe (einmalig anlegen)
const limiterNachPlan = {
free: rateLimit({
windowMs: 60 * 60 * 1000,
max: 100,
standardHeaders: 'draft-8',
legacyHeaders: false
}),
premium: rateLimit({
windowMs: 60 * 60 * 1000,
max: 1000,
standardHeaders: 'draft-8',
legacyHeaders: false
}),
enterprise: rateLimit({
windowMs: 60 * 60 * 1000,
max: 10000,
standardHeaders: 'draft-8',
legacyHeaders: false
})
};
// Dynamische Middleware: Limiter anhand des User-Plans wählen
const dynamischerLimiter = (req, res, next) => {
const plan = req.user?.plan || 'free';
const limiter = limiterNachPlan[plan] || limiterNachPlan.free;
return limiter(req, res, next);
};
// Simulierter JWT-Middleware-Stub (in Produktion durch echte JWT-Verifikation ersetzen)
const verifiziereJWT = (req, res, next) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (token === 'premium-token') {
req.user = { id: 'user123', plan: 'premium' };
} else if (token === 'enterprise-token') {
req.user = { id: 'enterprise1', plan: 'enterprise' };
}
next();
};
// Middleware-Kette: erst JWT prüfen, dann dynamisch begrenzen
app.use('/api/v1', verifiziereJWT, dynamischerLimiter);
Schritt 9: HTTP 429-Antworten professionell gestalten
Ein professioneller Rate-Limit-Response enthält mehrere Pflicht-Header, damit Clients wissen, wann sie es erneut versuchen können. Das ist besonders wichtig für mobile Apps und API-Clients, die automatisches Retry implementieren. Ein schlechter 429-Response führt zu unkontrolliertem Retry-Flooding, das das Problem verschlimmert statt löst.
const professionellerLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: 'draft-8',
legacyHeaders: false,
handler: (req, res, next, options) => {
const resetZeit = new Date(Date.now() + options.windowMs);
// Strukturierter Fehler-Response
res.status(options.statusCode).json({
fehler: {
code: 'RATE_LIMIT_EXCEEDED',
nachricht: 'Anfrage-Limit überschritten. Bitte warte und versuche es erneut.',
limit: options.max,
zeitfensterMinuten: options.windowMs / 60000,
resetZeit: resetZeit.toISOString(),
dokumentation: 'https://api.beispiel.at/docs/rate-limits'
}
});
}
});
// Globaler Fehlerhandler für konsistente 429-Behandlung
app.use((err, req, res, next) => {
if (err.status === 429) {
res.status(429).json({
fehler: 'Zu viele Anfragen.',
retryAfter: res.getHeader('Retry-After')
});
} else {
next(err);
}
});
Teste die HTTP-Header der 429-Antwort mit curl -v:
# 11 Login-Versuche senden (Limit ist 10):
for i in $(seq 1 11); do
curl -s -X POST http://localhost:3000/api/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","passwort":"falsch"}'
echo ""
done
# Beim 11. Versuch erhältst du:
# {
# "fehler": {
# "code": "RATE_LIMIT_EXCEEDED",
# "nachricht": "Anfrage-Limit überschritten...",
# "limit": 10,
# "zeitfensterMinuten": 15,
# "resetZeit": "2026-06-17T12:30:00.000Z"
# }
# }
# HTTP-Header anzeigen:
curl -v -X POST http://localhost:3000/api/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","passwort":"falsch"}' 2>&1 | grep -E "^[<>]"
# Erwartete Antwort-Header:
# < HTTP/1.1 429 Too Many Requests
# < RateLimit-Limit: 10
# < RateLimit-Remaining: 0
# < RateLimit-Reset: 1750163400
# < Retry-After: 847
Schritt 10: Logging und Monitoring für Rate-Limit-Events
Rate-Limit-Überschreitungen sind wertvolle Sicherheitssignale. Sie können auf laufende Angriffe, fehlerhafte API-Clients oder echte Nutzer hinweisen, die ein zu enges Limit erreichen. Ohne systematisches Logging ist es unmöglich, zwischen einem Angreifer und einem legitimen Nutzer mit hohem Bedarf zu unterscheiden.
const überwachterLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: 'draft-8',
legacyHeaders: false,
// Health-Checks vom Limit ausnehmen
skip: (req) => req.path === '/health' || req.path === '/metrics',
handler: (req, res, next, options) => {
// Sicherheits-Event strukturiert loggen
const logEintrag = {
zeitstempel: new Date().toISOString(),
ereignis: 'RATE_LIMIT_UEBERSCHRITTEN',
ip: req.ip,
pfad: req.path,
methode: req.method,
userAgent: req.headers['user-agent']?.substring(0, 100),
userId: req.user?.id || 'anonym',
schweregrad: 'WARNUNG'
};
console.warn(JSON.stringify(logEintrag));
// Hier kannst du z.B. Prometheus-Counter inkrementieren:
// metrics.rateLimitHits.inc({ path: req.path, ip: req.ip });
res.status(429).json({
fehler: 'Zu viele Anfragen.',
retryAfterSekunden: Math.ceil(options.windowMs / 1000)
});
}
});
// Response-Monitoring-Middleware
app.use((req, res, next) => {
res.on('finish', () => {
if (res.statusCode === 429) {
console.log(`[RATE-LIMIT] ${new Date().toISOString()} - ${req.ip} - ${req.method} ${req.path}`);
}
});
next();
});
Schritt 11: Rate Limiting automatisiert testen
Manuelles Testen mit Bash-Schleifen ist für CI/CD nicht reproduzierbar. Schreibe automatisierte Tests mit Jest und supertest, die bei jedem Deploy sicherstellen, dass das Rate Limiting wie erwartet funktioniert:
npm install --save-dev jest supertest
// tests/rateLimit.test.js
const request = require('supertest');
// Frische App-Instanz pro Testdatei (isoliert den Store)
let app;
beforeEach(() => {
// Modul-Cache leeren, damit jeder Test eine neue Instanz bekommt
jest.resetModules();
app = require('../index');
});
describe('Globales Rate Limiting', () => {
test('erlaubt die ersten 100 Anfragen', async () => {
for (let i = 0; i < 100; i++) {
const res = await request(app).get('/api/produkte');
expect(res.status).toBe(200);
}
});
test('blockiert die 101. Anfrage mit HTTP 429', async () => {
for (let i = 0; i < 100; i++) {
await request(app).get('/api/produkte');
}
const res = await request(app).get('/api/produkte');
expect(res.status).toBe(429);
expect(res.body.fehler).toBeDefined();
});
test('liefert korrekte RateLimit-Header', async () => {
const res = await request(app).get('/api/produkte');
expect(res.headers['ratelimit-limit']).toBeDefined();
expect(res.headers['ratelimit-remaining']).toBeDefined();
});
});
describe('Auth-spezifisches Rate Limiting', () => {
test('blockiert Login nach 10 fehlgeschlagenen Versuchen', async () => {
for (let i = 0; i < 10; i++) {
await request(app)
.post('/api/login')
.send({ email: '[email protected]', passwort: 'falsch' });
}
const res = await request(app)
.post('/api/login')
.send({ email: '[email protected]', passwort: 'falsch' });
expect(res.status).toBe(429);
expect(res.body.fehler).toMatch(/Zu viele/i);
});
});
Füge in package.json das Test-Skript hinzu:
"scripts": {
"test": "jest --testTimeout=30000 --runInBand"
}
npm test
# Erwartete Ausgabe:
# PASS tests/rateLimit.test.js
# Globales Rate Limiting
# ✓ erlaubt die ersten 100 Anfragen (2341 ms)
# ✓ blockiert die 101. Anfrage mit HTTP 429 (189 ms)
# ✓ liefert korrekte RateLimit-Header (45 ms)
# Auth-spezifisches Rate Limiting
# ✓ blockiert Login nach 10 fehlgeschlagenen Versuchen (312 ms)
Wichtig: Das Flag --runInBand stellt sicher, dass Tests sequenziell laufen und sich die In-Memory-Zähler nicht gegenseitig beeinflussen. Für parallele Tests müsstest du separate Express-Instanzen auf verschiedenen Ports oder echte Redis-Isolation einsetzen.
Schritt 12: nginx als vorgelagerten Rate Limiter nutzen
Für Produktionssysteme empfiehlt sich eine zweistufige Architektur: nginx übernimmt das grobe Filtern auf Netzwerkebene, Node.js das feinkörnige, anwendungsbewusste Limiting. Diese Kombination hat zwei Vorteile: Erstens erreichen die teuersten Angriffspakete den Node.js-Prozess gar nicht erst. Zweitens kann nginx wesentlich mehr Verbindungen pro Sekunde verarbeiten als ein Node.js-Prozess, da es kein JavaScript parsen muss.
Das ergänzt deine bestehende SSH-Härtung auf Serverebene und zusammen mit Fail2ban ergibt sich ein dreischichtiger Schutz: Firewall, nginx-Rate-Limit und Node.js-Limit.
# /etc/nginx/sites-available/meine-api
# Rate-Limiting-Zonen definieren (10 MB Speicher = ca. 160.000 IPs)
limit_req_zone $binary_remote_addr zone=api_global:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login_limit:10m rate=1r/m;
server {
listen 443 ssl http2;
server_name api.beispiel.at;
# SSL-Konfiguration (Let's Encrypt oder eigenes Zertifikat)
ssl_certificate /etc/letsencrypt/live/api.beispiel.at/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.beispiel.at/privkey.pem;
# Globales nginx-Limit: 10 Anfragen/Sek, Burst von 20 erlaubt
location /api/ {
limit_req zone=api_global burst=20 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:3000;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
}
# Strengeres Limit für Login: 1 Anfrage/Min, Burst von 5
location /api/login {
limit_req zone=login_limit burst=5 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:3000;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
# Konfiguration prüfen und nginx neu laden
sudo nginx -t
sudo systemctl reload nginx
# Test: Anfragen an Login-Endpunkt
for i in $(seq 1 8); do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
https://api.beispiel.at/api/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","passwort":"test"}')
echo "Anfrage $i: HTTP $STATUS"
done
# nginx-Rate-Limit-Log prüfen:
sudo tail -f /var/log/nginx/error.log | grep "limiting requests"
Häufige Fehler beim Rate Limiting (6 kritische Pitfalls)
Diese Fehler tauchen in Code-Reviews immer wieder auf und hebeln den Schutz vollständig oder teilweise aus. Alle sechs sind in produktiven Systemen dokumentiert worden.
Pitfall 1: In-Memory-Store in Multi-Prozess-Umgebung
Problem: Node.js-App läuft mit PM2 im Cluster-Modus mit 4 Prozessen. Jeder Prozess hat seinen eigenen In-Memory-Zähler. Ein Angreifer, der Anfragen über alle 4 Prozesse verteilt, erreicht effektiv das 4-fache Limit ohne gesperrt zu werden.
Lösung: Redis-Store verwenden (Schritt 6). Für kleinere Projekte ohne Redis: PM2 mit instances: 1 betreiben oder RateLimiterCluster aus rate-limiter-flexible einsetzen, der IPC für die Synchronisation nutzt.
// FALSCH in PM2-Cluster-Modus (jede Instanz zählt separat):
const store = new MemoryStore();
// RICHTIG: Redis als gemeinsamer Speicher für alle Instanzen:
const store = new RedisStore({ sendCommand: (...args) => redisClient.call(...args) });
Pitfall 2: trust proxy nicht konfiguriert
Problem: Ohne app.set('trust proxy', 1) sieht req.ip hinter nginx immer 127.0.0.1. Das Rate Limit gilt für alle Nutzer zusammen, ein einzelner Angreifer kann den Server für alle sperren.
Lösung: trust proxy korrekt setzen. Den Wert true nur in völlig kontrollierten internen Netzwerken verwenden. In Produktion lieber den exakten Hop-Count angeben.
Pitfall 3: Passwort-Reset und andere sensible Endpunkte vergessen
Problem: Entwickler begrenzen den Login-Endpunkt, vergessen aber /api/passwort-reset, /api/email-verifizierung-senden, /api/otp-code-senden und ähnliche Endpunkte. Diese können für E-Mail-Flooding, Telefon-Missbrauch oder Kontoenumerierung eingesetzt werden.
Lösung: Alle Endpunkte, die externe Aktionen auslösen (E-Mails, SMS, Webhooks), mit einem strikten Limiter absichern. Als Faustregel: Jeder Endpunkt, der Geld, Zeit oder Aufmerksamkeit des Nutzers kostet, braucht ein Limit.
Pitfall 4: IPv6-Subnetze ignoriert
Problem: IPv6 erlaubt einem Angreifer mit einem /48-Block (65.536 /64-Netze, je 18 Trilliarden Adressen) praktisch unbegrenzte IP-Rotation. IP-basiertes Rate Limiting wird damit trivial zu umgehen.
Lösung: IPv6-Anfragen auf /64-Subnet-Ebene limitieren, nicht auf einzelne /128-Adressen:
keyGenerator: (req) => {
const ip = req.ip || '';
// Bei IPv6: ersten 4 Blöcke als Subnet-Key verwenden (/64)
if (ip.includes(':')) {
return ip.split(':').slice(0, 4).join(':') + '::/64';
}
return ip; // IPv4 unverändert
}
Pitfall 5: Mobile-Nutzer hinter Carrier-NAT gesperrt
Problem: Viele Mobilfunk-Nutzer teilen in Österreich (A1, T-Mobile, Magenta) dieselbe öffentliche IPv4-Adresse über Carrier-Grade NAT (CGN). Ein zu enges IP-basiertes Limit sperrt bei Überschreitung Hunderte legitime Nutzer gleichzeitig.
Lösung: Für authentifizierte Endpunkte die User-ID als primären Schlüssel verwenden, IP nur als Fallback für nicht-authentifizierte Endpunkte. Das Limit für den öffentlichen Bereich großzügiger setzen (200 statt 100) und auf die Auth-Ebene verlagern.
Pitfall 6: Kein Retry-After-Header oder falsch befüllter Header
Problem: Der API-Client erhält eine 429-Antwort ohne Retry-After-Header oder mit einer falschen Zeitangabe. Viele API-Client-Bibliotheken implementieren exponentielles Backoff basierend auf diesem Header. Fehlt er, versuchen Clients oft sofort erneut, was das Problem verschlimmert.
Lösung: standardHeaders: 'draft-8' setzt alle nötigen Header automatisch. Im eigenen Handler immer res.set('Retry-After', retryAfterSekunden) setzen, bevor res.status(429).json() aufgerufen wird.
Troubleshooting: 9 häufige Probleme und ihre Lösungen
| Problem | Wahrscheinliche Ursache | Lösung |
|---|---|---|
req.ip zeigt immer 127.0.0.1 | trust proxy nicht gesetzt | app.set('trust proxy', 1) vor allen Middleware-Aufrufen |
| 429 nach 1 Anfrage (nicht nach dem konfigurierten Limit) | Redis-Verbindung fehlgeschlagen; Fallback-Zähler auf 0 gestartet | Redis-Status mit redis-cli ping prüfen; Verbindungs-Fehlerbehandlung hinzufügen |
| RateLimit-Header fehlen in der Antwort | standardHeaders nicht gesetzt oder auf Wert "false" | standardHeaders: 'draft-8', legacyHeaders: false setzen |
| Rate Limit gilt nicht für alle PM2-Instanzen | In-Memory-Store bei PM2-Cluster: jede Instanz zählt separat | Redis-Store oder RateLimiterCluster einsetzen |
| Jest-Tests scheitern zufällig | Zähler aus vorherigem Test läuft noch; Tests laufen parallel | --runInBand Flag und jest.resetModules() in beforeEach |
| Eigene Nutzer werden gesperrt | Limit zu eng oder Mobile-NAT-IP | User-ID als Schlüssel statt IP; Limit für unauthentifizierte Endpunkte erhöhen |
| Angreifer umgeht Limit durch IPv6-Rotation | Limit auf /128-Einzeladressen statt /64-Subnetz | IPv6-Subnet-Key-Generator implementieren (Pitfall 4) |
Too many connections in Redis | Jeder Request öffnet eine neue Redis-Verbindung | Redis-Client als Singleton anlegen und per Dependency Injection übergeben |
| Rate-Limit-Zähler nach Deploy zurückgesetzt | In-Memory-Store wird beim Neustart geleert | Redis-Store für persistente Zähler über Deployments hinweg verwenden |
Fortgeschrittene Techniken: Tarpitting und Progressive Blocking
Standard-Rate-Limiting blockiert sofort mit HTTP 429. Fortgeschrittene Systeme nutzen zwei weitere Techniken, die Angreifer erheblich effektiver abschrecken, weil sie den Zeitaufwand für automatisierte Angriffe dramatisch erhöhen.
Tarpitting (Künstliche Verzögerung): Statt sofort 429 zu senden, wartet der Server mehrere Sekunden, bevor er antwortet. Das verlangsamt Angreifer, die automatisierte Tools einsetzen, erheblich, ohne legitime Nutzer zu beeinträchtigen, da diese das Limit nicht erreichen:
// Verdächtige Anfragen künstlich verzögern
const tarpit = async (req, res, next) => {
const verdaechtig = !req.headers['authorization'] &&
req.headers['user-agent']?.includes('python-requests');
if (verdaechtig) {
// 3 Sekunden warten, bevor weitergemacht wird
await new Promise(resolve => setTimeout(resolve, 3000));
}
next();
};
app.post('/api/login', tarpit, authLimiter, loginHandler);
Progressive Blocking (Eskalierendes Sperren): Nach der ersten Überschreitung wird 1 Minute gesperrt, nach der zweiten 10 Minuten, nach der dritten 1 Stunde. Das macht automatisierte Angriffe exponentiell teurer:
const { RateLimiterMemory } = require('rate-limiter-flexible');
// Drei Eskalationsstufen
const limiterStufe1 = new RateLimiterMemory({ points: 10, duration: 60 });
const limiterStufe2 = new RateLimiterMemory({ points: 3, duration: 600, blockDuration: 600 });
const limiterStufe3 = new RateLimiterMemory({ points: 1, duration: 3600, blockDuration: 3600 });
const progressiveProtection = async (req, res, next) => {
try {
await limiterStufe1.consume(req.ip);
next();
} catch (e1) {
try {
await limiterStufe2.consume(req.ip);
console.warn(`Warnung: ${req.ip} auf Eskalationsstufe 2`);
next();
} catch (e2) {
try {
await limiterStufe3.consume(req.ip);
console.error(`Kritisch: ${req.ip} auf Eskalationsstufe 3`);
next();
} catch (e3) {
res.status(429).json({
fehler: 'IP temporär gesperrt.',
dauerStunden: 1
});
}
}
}
};
Diese Techniken ergänzen sich gut mit netzwerkseitiger Absicherung. Kombiniert mit WireGuard VPN für interne Services und SSH-Key-Authentifizierung entsteht eine mehrschichtige Verteidigung.
Vollständiges Beispielprojekt
Die folgende index.js fasst alle Schritte dieses Tutorials zu einer produktionsreifen Datei zusammen. Sie enthält globales und routen-spezifisches Rate Limiting, korrektes Proxy-Handling, strukturiertes Logging und einen testbaren App-Export.
'use strict';
require('dotenv').config(); // npm install dotenv
const express = require('express');
const rateLimit = require('express-rate-limit');
const app = express();
const PORT = process.env.PORT || 3000;
const NODE_ENV = process.env.NODE_ENV || 'development';
// Proxy-Konfiguration (anpassen je nach Infrastruktur)
app.set('trust proxy', 1);
app.use(express.json());
// ── Logging-Helper ─────────────────────────────────────────────────
const logRateLimit = (req, level = 'WARNUNG') => {
console.warn(JSON.stringify({
zeitstempel: new Date().toISOString(),
ereignis: 'RATE_LIMIT',
schweregrad: level,
ip: req.ip,
pfad: req.path,
methode: req.method,
userId: req.user?.id || 'anonym'
}));
};
// ── Limiter-Definitionen ──────────────────────────────────────────
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: 'draft-8',
legacyHeaders: false,
keyGenerator: (req) => req.user?.id || req.ip,
skip: (req) => ['/health', '/metrics'].includes(req.path),
handler: (req, res, next, options) => {
logRateLimit(req, 'WARNUNG');
res.status(429).json({
fehler: 'Zu viele Anfragen. Bitte warte 15 Minuten.',
retryAfterSekunden: Math.ceil(options.windowMs / 1000)
});
}
});
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: 'draft-8',
legacyHeaders: false,
skipSuccessfulRequests: true,
handler: (req, res, next, options) => {
logRateLimit(req, 'KRITISCH');
res.status(429).json({
fehler: 'Zu viele Anmeldeversuche. Warte 15 Minuten.',
code: 'AUTH_RATE_LIMIT'
});
}
});
// ── Globales Limit für alle Routen ────────────────────────────────
app.use(globalLimiter);
// ── Öffentliche Routen ────────────────────────────────────────────
app.get('/health', (req, res) => res.json({ status: 'ok', umgebung: NODE_ENV }));
app.get('/api/produkte', (req, res) => {
res.json({ produkte: ['Laptop', 'Maus', 'Tastatur'] });
});
// ── Auth-Routen (striktes Limit) ──────────────────────────────────
app.post('/api/login', authLimiter, (req, res) => {
const { email, passwort } = req.body || {};
if (!email || !passwort) {
return res.status(400).json({ fehler: 'E-Mail und Passwort erforderlich' });
}
if (email === '[email protected]' && passwort === 'geheim123') {
return res.json({ token: 'jwt-token-platzhalter' });
}
res.status(401).json({ fehler: 'Ungültige Zugangsdaten' });
});
app.post('/api/passwort-reset', authLimiter, (req, res) => {
const { email } = req.body || {};
if (!email) return res.status(400).json({ fehler: 'E-Mail erforderlich' });
res.json({ nachricht: 'Falls das Konto existiert, wurde ein Reset-Link gesendet.' });
});
// ── Server starten ────────────────────────────────────────────────
if (require.main === module) {
app.listen(PORT, () => {
console.log(`Server läuft auf Port ${PORT} [${NODE_ENV}]`);
});
}
module.exports = app;
Weiterführende Artikel
Verwandte Themen auf shattered.io
- Digitale Signatur in Node.js: 11 Schritte, 40 Minuten
- SSH-Key einrichten: Server härten in 10 Schritten
- Fail2ban einrichten: SSH-Schutz in 12 Schritten
- Cyberangriffe DACH 2026: 289 Mrd. Euro Schaden
- WireGuard einrichten: VPN-Server in 12 Schritten
FAQ: Rate Limiting in Node.js
Was ist der Unterschied zwischen Rate Limiting und Throttling?
Rate Limiting blockiert Anfragen hart, sobald das Limit erreicht ist (HTTP 429). Throttling verzögert sie stattdessen, d.h. die Anfrage wird langsamer bearbeitet, aber nicht abgelehnt. Für Sicherheitszwecke ist Rate Limiting mit hartem Blocking besser geeignet. Throttling passt eher zu Szenarien, wo ein reibungsloses Nutzererlebnis wichtiger ist als strikte Durchsetzung, etwa bei Streaming-APIs.
Welchen Algorithmus soll ich für den Login-Endpunkt verwenden?
Für Login-Endpunkte empfiehlt sich der Token-Bucket-Algorithmus über rate-limiter-flexible kombiniert mit progressivem Blocking: Nach 5 fehlgeschlagenen Versuchen 1 Minute sperren, nach 10 insgesamt 15 Minuten, nach 20 den Account temporär deaktivieren und den Nutzer per E-Mail benachrichtigen. skipSuccessfulRequests: true stellt sicher, dass normale Nutzer nicht bestraft werden.
Brauche ich Redis für Rate Limiting in einem kleinen Projekt?
Nein. Wenn du eine einzelne Node.js-Instanz betreibst (kein PM2-Cluster, kein Kubernetes, kein Load Balancer mit mehreren Instanzen), reicht der In-Memory-Store von express-rate-limit aus. Der einzige Nachteil: Beim Neustart des Servers werden alle Zähler zurückgesetzt. Angreifer, die einen Neustart erzwingen können, umgehen das Limit damit kurz. Für produktive Systeme mit Skalierungsanforderungen ist Redis unverzichtbar.
Wie nehme ich vertrauenswürdige IPs vom Limit aus?
Verwende die skip-Option von express-rate-limit, um interne IPs oder Health-Check-Adressen vom Limit auszunehmen. Achtung: Diese IPs sollten aus einer Konfigurationsdatei oder Umgebungsvariable kommen, nie fest im Code stehen:
const VERTRAUENSWUERDIGE_IPS = new Set(
(process.env.WHITELISTED_IPS || '').split(',').filter(Boolean)
);
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
skip: (req) => VERTRAUENSWUERDIGE_IPS.has(req.ip)
});
Welche HTTP-Header sendet express-rate-limit 8.x?
Mit standardHeaders: 'draft-8' sendet express-rate-limit 8.5.2 die Header RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset und bei Überschreitung Retry-After, gemäß dem IETF-Entwurf für HTTP Rate Limit Headers. Die alten X-RateLimit-*-Header aus Version 6.x sind mit legacyHeaders: false deaktiviert.
Ersetzt Rate Limiting eine Web Application Firewall?
Nein. Rate Limiting und WAF sind komplementäre Sicherheitsschichten. Eine WAF erkennt Angriffsmuster im Payload (SQL-Injection, XSS, Path Traversal). Rate Limiting begrenzt die Anzahl der Anfragen, unabhängig vom Inhalt. Beide Schichten gehören in eine produktionsreife Sicherheitsarchitektur. Für Österreich und die DACH-Region bieten Anbieter wie Cloudflare, AWS WAF und Fastly fertige WAF-Lösungen.
Kann ein Angreifer IP-basiertes Rate Limiting umgehen?
Ja, durch IP-Rotation über Proxys, Tor, gemietete Botnetze oder IPv6-Adress-Rotation. Kombiniere deshalb mehrere Identifizierungssignale: IP-Adresse, User-Agent, Device-Fingerprint, Account-ID und Verhaltensmuster. Für hochwertige Endpunkte bietet sich zusätzlich ein CAPTCHA nach einer bestimmten Anzahl Fehlversuche an, das automatisierte Rotation unwirksam macht.
Wie setze ich Rate-Limit-Zähler im Jest-Test zurück?
Bei In-Memory-Store: jest.resetModules() in beforeEach aufrufen und die App-Instanz neu laden. Das erstellt einen frischen Zähler. Bei Redis-Store: Den betreffenden Key löschen mit redis-cli DEL "rl:127.0.0.1" oder programmatisch mit redisClient.del('rl:127.0.0.1'). Alternativ einen Test-spezifischen Key-Prefix verwenden, der nach dem Test vollständig gelöscht wird.




