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) 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 creationAdmin account (across organizations)burst 20, then ~20/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
# Total concurrent streams
SSE_MAX_CONNECTIONS=2000
# Per client IP
SSE_MAX_CONNECTIONS_PER_IP=50
# Force-close after 6 hours; clients reconnect
SSE_MAX_LIFETIME_MS=21600000

Keep comments on their own line in .env: the loader has no inline-comment syntax, so anything after the = becomes part of the value (see Environment Variables).

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, refilling at that same figure per minute. The daily AI and export limits reset at midnight UTC.

API responses include rate limit headers that report the per-minute bucket, the same decision that answers 429:

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57
X-RateLimit-Reset: 1706140803
  • X-RateLimit-Limit: the bucket capacity, which is the tier’s requests per minute
  • X-RateLimit-Remaining: whole requests still available after this one
  • X-RateLimit-Reset: Unix timestamp at which the bucket is full again

Export downloads and daily-limit errors use the same header names for the daily quota instead: there, X-RateLimit-Limit is the daily export or AI limit and X-RateLimit-Reset is the next midnight UTC.

When rate limited, the API returns HTTP Status: 429 Too Many Requests.

Exceeding the per-minute limit returns the X-RateLimit-* headers, a Retry-After header with the seconds until the next request is admitted, and:

{
"error": "rate_limited",
"message": "Rate limit exceeded. Please slow down your requests."
}

Exceeding a daily limit returns the X-RateLimit-* headers (no Retry-After) 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. Redis runs the same token bucket as the in-memory limiter, with the same capacity and refill, so both behave alike: a burst up to the capacity, then the steady refill rate. Each request is one atomic step in Redis (refill, take a token, decide), so concurrent instances cannot spend the same token twice. The keys live under ratelimit:token-bucket:. If Redis is unreachable, Deckyard falls back to a bucket in the local process, which does not share its budget with other instances.

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

The event stream caps are always per instance, even with Redis. 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.