Rate Limiter
Restrict API request rates per client IP, return 429 responses with Retry-After headers, and share counters across clusters with Redis.
VitNode includes automated IP-based rate limiting middleware. Exceeding the request budget immediately returns 429 Too Many Requests with a Retry-After header, protecting authentication and public endpoints from brute-force attacks.
Quick start
Customize the rate limiter budget in apps/api/src/vitnode.api.config.ts:
import { buildApiConfig } from "@vitnode/core/vitnode.config"
export const vitNodeApiConfig = buildApiConfig({
rateLimiter: {
points: 60, // Max requests allowed
duration: 60, // Window in seconds
},
})The default configuration allows 80 requests per 60 seconds per IP address.
Disabled in development
Rate limiting is automatically bypassed when NODE_ENV=development to prevent interruptions during local coding.
429 Error Response Format
When a client exhausts their request allowance, the API returns a structured JSON error:
HTTP/1.1 429 Too Many Requests
Retry-After: 24
Content-Type: application/json
{
"error": "Too Many Requests",
"retryAfter": 24
}Distributed Rate Limiting (Redis)
- Without Redis: Counters are tracked in local memory per server instance.
- With Redis: Counters are synchronized across all API containers using a distributed sliding window.
Configure Redis in vitnode.api.config.ts:
export const vitNodeApiConfig = buildApiConfig({
redis: process.env.REDIS_URL
? { url: process.env.REDIS_URL, password: process.env.REDIS_PASSWORD }
: undefined,
})Real visitor IPs for server-rendered pages
When your web app renders a page on the server, it calls the API itself - so the API sees the web server's address, not your visitor's. Every server-rendered request then lands in the same bucket, and 80 requests a minute disappears fast on a busy site.
The API never believes a plain X-Forwarded-For header (anyone can type one). Instead, the web server works out the visitor's address, signs it with a key derived from your CRON_SECRET - the secret the web app and the API already share - and the API checks that signature before trusting it.
- Set the same
CRON_SECRETin both apps (you need it there anyway). - Tell the web app how many proxies sit in front of it with
VITNODE_TRUSTED_PROXY_HOPS. It skips exactly that many hops from the right ofX-Forwarded-Forand ignores everything further left, because a visitor can write whatever they like there. - The API trusts the forwarded address only when the signature matches and is less than 5 minutes old.
- While
CRON_SECRETis unset or still a published placeholder, nothing is trusted: the API keys every request on the connection address.
| Setup | VITNODE_TRUSTED_PROXY_HOPS |
|---|---|
| Web app exposed directly, no proxy (default) | 0 |
| One reverse proxy (Nginx, Caddy, a load balancer, Vercel) | 1 |
| A CDN in front of your reverse proxy | 2 |
Count your proxies, not your hopes
A number higher than the proxies you actually run lets visitors choose their own address again. With the default 0, every server-rendered request behind a proxy shares the proxy's bucket, so the limit is safe but tight.
One secret, two jobs
Anyone holding CRON_SECRET can trigger cron jobs and pick any IP address the rate limiter will believe. Use a long random value and rotate it in both apps together.
rateLimiter Options
Prop
Type