Rate Limits & Quotas Reference
The four ceilings that can refuse a request, the plan and quota numbers behind them, the three 429 shapes, and how to tell them apart.
Overview
Four independent ceilings can refuse a request. They run in front of each other, so the one you hit tells you which resource ran out.
- Cloudflare edge ceiling. 1,200 requests per minute per source IP, counted per Cloudflare data centre, across all of
api.passkeybridge.io. Blocks for 60 seconds. The health and Stripe webhook paths are exempt. - Per-IP rate limit. Two layers inside the function: an in-isolate sliding window and a database-backed counter, keyed by function, tenant and a keyed digest of the source IP.
- Per-tenant quota. A database counter summed across every caller of the tenant, applied by functions that opt into it.
- Volume allotments. The Starter monthly signal cap and the sandbox and test-mode rolling allotment. These bound billed and unbilled volume rather than request rate.
The first three answer 429. The Starter cap answers 429 and the unpaid-subscription variant answers 402.
Who this applies to: every tenant on every plan. Nothing here is configurable from the dashboard. A tenant that needs a higher per-minute rate has rate_limit_per_minute set on its row by support; the per-tenant quota has its own override table.
Per-IP rate limits
Per-IP enforcement uses two layers, and both are best understood by what they can and cannot promise.
Layer 1, in-isolate. A sliding window held in module memory, default 60 requests per 60 seconds. It costs nothing and absorbs a burst that arrives inside one isolate's lifetime. It is a damper rather than a floor: Supabase edge isolates boot per request under sequential traffic (measured 2026-09-06: 30 requests at half-second intervals produced 30 boots), so the in-memory map is usually empty on arrival and admits the first request of every isolate. The map is capped at 50,000 keys, and the oldest key is evicted on insert once that cap is reached.
Layer 2, database-backed. One atomic call to the shield_check_rate_limit function, which deletes the expired window, increments and returns the new count. This is the layer that actually holds, because it is unaffected by isolate lifetime.
The key is {function}:{tenant_id}:{ip_hash}, so a client's budget is per function rather than shared across the API surface, and the IP is a keyed HMAC digest rather than the address itself.
Signal ingest is different in two ways. Its pre-authentication per-IP bucket is 1,200 per minute, the highest plan default, so a tenant posting from one egress address is never held below its plan before the tenant is known. And its distributed check is issued concurrently with the tenant lookup and decided afterwards, so the counter advances even on requests the in-isolate layer would have refused. That makes the limiter marginally stricter during a burst, and it is the reason a burst that the in-isolate layer absorbs still reaches the database.
When the distributed layer fails, the request is served. For an identity vendor a database blip must not become an authentication outage, so the limiter falls back to the in-isolate verdict and marks it: the result carries degraded, the response carries X-RateLimit-Degraded: true, and the function logs an error line marked L2_DEGRADED. While that is true the limit is per isolate and the distributed guarantee does not hold.
Client IP extraction
The IP used in the rate-limit key is resolved in a fixed trust order. Provider-set headers come first because the edge rewrites them on every hop; x-forwarded-for is consulted last because a client can set it when no trusted proxy sits in front.
cf-connecting-ipfly-client-ipx-real-ip- the left-most comma-separated value of
x-forwarded-for - a per-request
anon-<uuid>bucket when none of the above yields a value
The anon-<uuid> fallback gives each unidentifiable request its own bucket. Collapsing unknown traffic into a single shared counter would let one bad actor exhaust the budget for every other unknown caller.
The resolved address is never used as the key directly. It goes through hashIp, a keyed HMAC-SHA-256 digest under the server-held pepper, because the key is persisted in shield_rate_limits and a raw address there is stored personal data. hashIp returns null for the anon- buckets and for the literal unknown, so those never become digests that look like real hosts.
The worker sets X-Forwarded-For and X-Real-IP from CF-Connecting-IP before forwarding, so a request that arrives through api.passkeybridge.io carries the real client address in a header the function trusts.
Plan limits and tenant quotas
Two separate numbers apply, and they come from two different places.
Per-minute rate limit resolves in priority order: the tenant's rate_limit_per_minute column when it is a positive integer, then the plan default, then a global fallback of 60.
| Plan | Requests per minute |
|---|---|
starter | 60 |
growth | 300 |
pro | 300 |
enterprise | 600 |
enterprise_dedicated | 1,200 |
| unknown plan | 60 |
growth is the legacy name for the paid mid tier and resolves to the same number as pro. enterprise_dedicated is not purchasable; it exists for tenants provisioned by hand. The column defaults to 0, which means unset, so a tenant upgraded through Stripe picks up its plan default without anyone touching the row.
Per-tenant quota is a separate counter, summed across every caller of the tenant rather than per IP, and applied only by functions that opt into it. The limit comes from the shield_tenant_quotas override row when one exists, otherwise from the effective plan:
| Plan | Requests per minute | Requests per hour |
|---|---|---|
enterprise | 30,000 | 1,500,000 |
pro or growth | 3,000 | 150,000 |
starter | 600 | 30,000 |
| unknown plan | 60 | 3,000 |
Every caller in the platform asks for the minute scope; the hour scope exists in the counter and nothing uses it today.
Effective plan, not purchased plan. A tenant whose billing_status is unpaid or cancelled is resolved as Starter for both tables until the card is fixed. past_due keeps the purchased tier, because Stripe is still retrying.
Signal ingest does not enforce the per-tenant quota. It applies the per-IP layers with the plan number and then its own monthly allotment. The quota is enforced by the functions that pass enforceQuota to the shared auth module, which is most of the key-authenticated surface.
429 response headers
Per-IP and plan refusal.
HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 300
X-RateLimit-Remaining: 0
X-RateLimit-Layer: l2
Content-Type: application/json
{ "error": "Too many requests. Please retry after 60 seconds.",
"limit": 300, "remaining": 0, "layer": "l2" }X-RateLimit-Layer names the layer that refused. X-RateLimit-Degraded: true is added when the distributed layer could not be consulted, so the verdict was per isolate.
Per-tenant quota refusal.
HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 3000
X-RateLimit-Remaining: 0
X-RateLimit-Scope: minute
x-pb-reason: quota-exceeded
Content-Type: application/json
{ "error": "tenant_quota_exceeded", "scope": "minute",
"limit": 3000, "retry_after_seconds": 60 }Simple refusal. Endpoints using the basic helper (the pre-authentication bucket on signal ingest, the public agent verify action, shield-ntp, shield-okta-hooks, the browser error reporter) answer with no layer field:
HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
{ "error": "Too many requests. Please retry after 60 seconds." }Known reporting defect. That helper hard-codes X-RateLimit-Limit: 60. On signal ingest the limit actually applied is 1,200 per minute per IP, so the header understates it by a factor of twenty. The refusal itself is correct. Use Retry-After, which is accurate in all three shapes.
X-RateLimit-Remaining is always 0 on a refusal, and there is no reset timestamp header in any shape.
Client handling and other ceilings
Honour `Retry-After`. It is 60 seconds on every application refusal and 3,600 on the hour-scope quota. The window is fixed rather than exponential, so retrying earlier earns another refusal.
Tell the four `429` shapes apart before you back off.
| Signal | Meaning | What changes it |
|---|---|---|
layer in the body | per-IP or plan limit | spread the load, or raise the plan |
x-pb-reason: quota-exceeded | per-tenant quota | reduce tenant-wide concurrency, or ask for an override |
x-pb-reason: sandbox-cap | sandbox and test-mode allotment | use a live key for production traffic |
no x-pb-reason at all | the Cloudflare edge ceiling | spread across source addresses |
Treat `429` as recoverable. The distributed layer fails open to the in-isolate layer on a database error, so a refusal always reflects a budget decision and never an outage.
Other ceilings worth knowing.
- Cloudflare edge: 1,200 requests per minute per source IP per data centre on
api.passkeybridge.io, blocking for 60 seconds. A continuous integration check keeps that ceiling at or above the highest plan default, so the application refusal is the one customers see first. - Sandbox and test mode:
pb_test_keys andx-pb-test-moderequests share 1,000 signals per rolling 30 days per tenant. Refused withx-pb-reason: sandbox-capand a message naming the live-key remedy. This traffic is never metered. - Starter monthly cap: 100 accepted signals per calendar month, refused with
Starter plan monthly signal limit reached (100). Upgrade at /pricing to keep processing signals.Test-mode and sandbox signals are exempt and do not consume it. - Unpaid subscription: a paid tenant whose card retries are exhausted is held to the Starter allotment and answers
402withcode: payment_requiredand abilling_url, rather than429. - Per-action caps: hosted passkey registration is 10 per minute per user handle, authentication 300 per minute per tenant, credential import 5 calls per minute per tenant, and entropy receipts 60 per minute per tenant.
Raising a limit. Plan defaults move with the plan. An explicit rate_limit_per_minute and a shield_tenant_quotas row are both set by PasskeyBridge support; there is no self-serve control for either.
Related from the blog
- Feeding a Hard Deny into Your Fraud Rules Engine: Integration Patternsengineering · 10 min read
- How to Benchmark an Identity Verification API: Latency, Freshness, Failure Injectionengineering · 12 min read
- The Pre-Filter Pattern: Paying for Heavy Fraud Signals Only on the Suspicious Tailsecurity · 9 min read