Skip to content

Rate Limiting

Deckyard includes built-in rate limiting to protect against abuse and ensure fair usage.

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.

When running behind a reverse proxy (nginx, Caddy, etc.), enable proxy trust to get the real client IP:

Terminal window
TRUST_PROXY=true

TRUST_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:

  1. X-Forwarded-For: the entry TRUSTED_PROXY_COUNT hops from the right of the chain (default 1, minimum 1). The leftmost entry is supplied by the client and is never trusted.
  2. X-Real-IP, if X-Forwarded-For is missing, shorter than the configured hop count, or the selected entry is not a valid IP.
  3. The socket’s remote address.

Without TRUST_PROXY, only the socket address is used.

Terminal window
# Number of trusted proxies that append to X-Forwarded-For (default 1)
TRUSTED_PROXY_COUNT=1

Set 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.

Capacity is the burst; the sustained rate is what the bucket refills to after the burst is spent.

EndpointKeyed byLimit
Password loginIPburst 10, then ~6/min
Password loginEmailburst 8, then ~6/min
Password reset requestsEmail3 per hour
Password reset requestsIP10 per hour
Magic link requestsEmail5 per hour
Magic link requestsIP15 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.

EndpointKeyed byLimit
Share link password (POST /api/share/:token/verify)IPburst 3, then ~3/hour
Guest verification requestsEmail3 per hour
EndpointKeyed byLimit
Code creationIPburst 10, then ~10/hour
Code resolutionIPburst 60, then ~60/hour
EndpointKeyed byLimit
Notes companion write (PUT /api/live-sessions/:id/notes/:slideId)IPburst 30, then ~1/second
EndpointKeyed byLimit
Tracking: session startIPburst 10, then 1 per 2 seconds
Tracking: heartbeatIPburst 20, then 2/second
Tracking: heartbeatSessionburst 5, then 1 per 2 seconds
Tracking: session endIPburst 10, then 1/second
Tracking: slide viewIPburst 30, then 3/second
Tracking: slide viewSessionburst 10, then 1/second
Tracking: erase own dataIPburst 10, then 1 per 5 seconds
Analytics (authenticated)Userburst 60, then 1/second
Report creation, GDPR export and deletionUserburst 10, then 1 per 5 seconds
Public report accessIPburst 10, then 1 per 5 seconds
EndpointKeyed byLimit
User creationAdmin20 per hour

Only one bulk export can run per user at a time; a second request returns 429 with error export_in_progress.

On instances running in demo or sandbox mode, expensive app routes are also limited per IP:

Route groupLimit
Exportburst 8, then ~15/min
Publishburst 6, then ~12/min
Create presentationburst 6, then ~12/min
Update presentationburst 30, then ~60/min
Follow (POST /api/follow/...)burst 20, then ~60/min

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:

Terminal window
SSE_MAX_CONNECTIONS=2000 # total concurrent streams
SSE_MAX_CONNECTIONS_PER_IP=50 # per client IP
SSE_MAX_LIFETIME_MS=21600000 # force-close after 6 hours; clients reconnect

The 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.

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:

TierRequests per minuteAI calls per dayExports per day
Free601050
Pro300100500
Enterprise1000unlimitedunlimited

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: 86400
X-RateLimit-Remaining: 86155
X-RateLimit-Reset: 1706140800

These 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.

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.

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.

  • IPv4: 192.168.1.1
  • IPv6: 2001:0db8:85a3::8a2e:0370:7334
  • IPv4-mapped IPv6: ::ffff:192.168.1.1
  • Bracketed IPv6: [::1]

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.

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.