Rate Limiting
Deckyard includes built-in rate limiting to protect against abuse and ensure fair usage.
Overview
Section titled “Overview”Most limits are token buckets. Each bucket has a capacity (the burst size) and a refillPerSec (the sustained rate):
- Burst traffic is allowed up to the bucket capacity
- Tokens refill at a steady rate
- Each request consumes one token; an empty bucket answers
429 Too Many Requests
Depending on the endpoint, a bucket is keyed per client IP, per signed-in user, per email address, per tracking session or per API key (see the tables below). A few older limits (password reset, magic links, guest verification, admin user creation) count requests in a one-hour window instead.
Configuration
Section titled “Configuration”Trust Proxy
Section titled “Trust Proxy”When running behind a reverse proxy (nginx, Caddy, etc.), enable proxy trust to get the real client IP:
TRUST_PROXY=trueTRUST_PROXY accepts true, 1, yes or on; anything else leaves it off. Without it, every request appears to come from the proxy, so all users share one per-IP bucket (including the login brute-force throttle).
With TRUST_PROXY enabled, Deckyard reads the client IP in this order:
X-Forwarded-For: the entryTRUSTED_PROXY_COUNThops from the right of the chain (default1, minimum1). The leftmost entry is supplied by the client and is never trusted.X-Real-IP, ifX-Forwarded-Foris missing, shorter than the configured hop count, or the selected entry is not a valid IP.- The socket’s remote address.
Without TRUST_PROXY, only the socket address is used.
# Number of trusted proxies that append to X-Forwarded-For (default 1)TRUSTED_PROXY_COUNT=1Set TRUSTED_PROXY_COUNT to the number of proxies you run in front of Deckyard (for example 2 for a load balancer in front of Caddy).
⚠️ Security: Only enable this when running behind a trusted proxy that sets these headers. Otherwise, clients can spoof their IP address.
Endpoint Limits
Section titled “Endpoint Limits”Capacity is the burst; the sustained rate is what the bucket refills to after the burst is spent.
Authentication
Section titled “Authentication”| Endpoint | Keyed by | Limit |
|---|---|---|
| Password login | IP | burst 10, then ~6/min |
| Password login | burst 8, then ~6/min | |
| Password reset requests | 3 per hour | |
| Password reset requests | IP | 10 per hour |
| Magic link requests | 5 per hour | |
| Magic link requests | IP | 15 per hour |
A blocked login returns 429. A rate-limited password reset or magic link request still answers 200 with the usual “if your email is registered” message, so the limit cannot be used to probe for accounts; the attempt is logged as an auth event.
Share Links
Section titled “Share Links”| Endpoint | Keyed by | Limit |
|---|---|---|
Share link password (POST /api/share/:token/verify) | IP | burst 3, then ~3/hour |
| Guest verification requests | 3 per hour |
Follow Codes
Section titled “Follow Codes”| Endpoint | Keyed by | Limit |
|---|---|---|
| Code creation | IP | burst 10, then ~10/hour |
| Code resolution | IP | burst 60, then ~60/hour |
Live Sessions
Section titled “Live Sessions”| Endpoint | Keyed by | Limit |
|---|---|---|
Notes companion write (PUT /api/live-sessions/:id/notes/:slideId) | IP | burst 30, then ~1/second |
Analytics
Section titled “Analytics”| Endpoint | Keyed by | Limit |
|---|---|---|
| Tracking: session start | IP | burst 10, then 1 per 2 seconds |
| Tracking: heartbeat | IP | burst 20, then 2/second |
| Tracking: heartbeat | Session | burst 5, then 1 per 2 seconds |
| Tracking: session end | IP | burst 10, then 1/second |
| Tracking: slide view | IP | burst 30, then 3/second |
| Tracking: slide view | Session | burst 10, then 1/second |
| Tracking: erase own data | IP | burst 10, then 1 per 5 seconds |
| Analytics (authenticated) | User | burst 60, then 1/second |
| Report creation, GDPR export and deletion | User | burst 10, then 1 per 5 seconds |
| Public report access | IP | burst 10, then 1 per 5 seconds |
Admin Operations
Section titled “Admin Operations”| Endpoint | Keyed by | Limit |
|---|---|---|
| User creation | Admin | 20 per hour |
Only one bulk export can run per user at a time; a second request returns 429 with error export_in_progress.
Demo and Sandbox Mode
Section titled “Demo and Sandbox Mode”On instances running in demo or sandbox mode, expensive app routes are also limited per IP:
| Route group | Limit |
|---|---|
| Export | burst 8, then ~15/min |
| Publish | burst 6, then ~12/min |
| Create presentation | burst 6, then ~12/min |
| Update presentation | burst 30, then ~60/min |
Follow (POST /api/follow/...) | burst 20, then ~60/min |
Live Event Streams
Section titled “Live Event Streams”The public follow, questions and live-session event streams (Server-Sent Events) are capped by concurrent connections rather than by request rate. These caps are tunable:
SSE_MAX_CONNECTIONS=2000 # total concurrent streamsSSE_MAX_CONNECTIONS_PER_IP=50 # per client IPSSE_MAX_LIFETIME_MS=21600000 # force-close after 6 hours; clients reconnectThe per-IP cap only applies when the observed client IP is a public address. Behind a proxy without TRUST_PROXY, every stream shares the proxy’s private address, so only the global cap applies. Over the cap, the stream answers 429 with Retry-After: 30.
API Rate Limits
Section titled “API Rate Limits”The public API (/api/v1) and MCP tool calls made with an API key share one per-minute bucket per key, sized by the key’s tier:
| Tier | Requests per minute | AI calls per day | Exports per day |
|---|---|---|---|
| Free | 60 | 10 | 50 |
| Pro | 300 | 100 | 500 |
| Enterprise | 1000 | unlimited | unlimited |
The tiers are rate-limit classes stored on each key, not plans. Every new key gets the free tier, and there is no setting in the app to change it; an admin who needs a higher limit changes the key’s tier column in the api_keys table.
The per-minute limit is a token bucket with the per-minute figure as its burst. The daily AI and export limits reset at midnight UTC.
API responses include rate limit headers:
X-RateLimit-Limit: 86400X-RateLimit-Remaining: 86155X-RateLimit-Reset: 1706140800These headers describe daily usage and reset at midnight UTC (X-RateLimit-Reset is a Unix timestamp). On regular requests, X-RateLimit-Limit is the per-minute rate times 1440, an informational daily ceiling; on export downloads and daily-limit errors it is the daily export or AI limit.
Handling Rate Limits
Section titled “Handling Rate Limits”When rate limited, the API returns HTTP Status: 429 Too Many Requests.
Exceeding the per-minute limit returns Retry-After: 60 and:
{ "error": "rate_limited", "message": "Rate limit exceeded. Please slow down your requests."}Exceeding a daily limit returns the X-RateLimit-* headers and:
{ "error": "rate_limited", "message": "Daily export limit exceeded", "details": { "limit": 50, "used": 50, "resetAt": "1706140800" }}The app’s own endpoints (outside /api/v1) answer 429 with a Retry-After header (in seconds) and a body like { "ok": false, "error": "rate_limited", "message": "Rate limit exceeded" }.
Honor Retry-After where present, and implement exponential backoff in your API clients to handle rate limits gracefully.
IP Address Detection
Section titled “IP Address Detection”Deckyard validates IP addresses taken from headers. An X-Forwarded-For chain that is too short for TRUSTED_PROXY_COUNT, or whose selected entry is malformed, is ignored and logged as a warning:
Untrusted/short X-Forwarded-For (len 1, hops 2)A malformed X-Real-IP is ignored silently.
Supported IP Formats
Section titled “Supported IP Formats”- IPv4:
192.168.1.1 - IPv6:
2001:0db8:85a3::8a2e:0370:7334 - IPv4-mapped IPv6:
::ffff:192.168.1.1 - Bracketed IPv6:
[::1]
Scaling Considerations
Section titled “Scaling Considerations”When Redis is configured (REDIS_URL or REDIS_HOST, and REDIS_ENABLED not set to false), the token-bucket limits are stored in Redis and shared across all instances. There, each limit is enforced as a sliding window: at most capacity requests per capacity / refillPerSec seconds (for example 10 login attempts per IP per 100 seconds). This allows the same long-run rate as the bucket but no extra burst. If Redis is unreachable, Deckyard falls back to in-memory buckets.
Without Redis, the limiter uses in-memory storage. For multi-instance deployments:
- Each instance maintains its own rate limit buckets
- Effective rate limits are multiplied by the number of instances
- Restarting an instance resets its buckets
Some limits are always per instance, even with Redis: admin user creation and the event stream caps. Password reset, magic link and guest verification limits are counted in the database, so they already hold across instances.
Customizing Limits
Section titled “Customizing Limits”There are no environment variables to change or disable the request-rate limits; only the event stream caps above are tunable. The token-bucket values live in one file, server/config/rate-limits.js, which you can edit in your own build:
export const FOLLOW_CODE_LIMITS = { create: { capacity: 10, refillPerSec: 10 / 3600 }, // burst 10, ~10/hour resolve: { capacity: 60, refillPerSec: 60 / 3600 }, // burst 60, ~60/hour};The public API tiers are defined in TIER_LIMITS in server/storage/api-keys.js. The login and share-link password limits are security-relevant; loosening them weakens brute-force protection.