Rate Limits
LumoAuth applies rate limits to specific authentication and provisioning flows to protect against abuse (credential stuffing, push bombing, mail bombing, and high-volume provisioning) and to ensure service availability for all organizations.
Rate Limit Headers
Rate limiting is enforced per flow at the endpoint level, plus one per-credential limit on the management and decision APIs. Ordinary 2xx responses do not carry X-RateLimit-* headers.
When a flow's limit is exceeded the server responds with 429 Too Many Requests and a Retry-After header indicating how many seconds to wait:
Retry-After: 30
| Header | Description |
|---|---|
Retry-After | Seconds to wait before retrying (only present on 429 responses) |
Limits by Endpoint
The limits below apply to the hosted service. They are set per deployment and cannot be changed by an organization.
| Flow | Default limit | Window |
|---|---|---|
| OAuth token endpoint | 100 requests | per 60 seconds |
| OAuth authorize endpoint | 60 requests | per 60 seconds |
| Other OAuth protocol endpoints (fixed) | PAR 60, CIBA bc-authorize 60, device authorization 30, introspection 100, revocation 100, dynamic client registration 10 | per 60 seconds |
| General API requests | 1,000 requests | per 60 seconds |
Per credential: Admin API, /api/v1/authz/**, Zanzibar expand, ABAC check-bulk, /me/** | 600 requests per API key, access token or session user | per 60 seconds |
| SCIM API | 500 requests | per 60 seconds |
| SCIM bulk | 10 requests | per hour |
Login attempts (portal login, /orgs/{orgId}/login, magic link, push-auth login) | 5 attempts per IP and per email | per 15 minutes |
Hosted OAuth login form (POST /orgs/{orgId}/api/v1/oauth/login/submit) | same login_attempts budget: 5 per IP and per email | per 15 minutes |
Hosted OAuth JSON login (POST /orgs/{orgId}/api/v1/oauth/login/json) | same login_attempts budget: 5 per IP and per email | per 15 minutes |
| Forgot password | 3 per IP + email and 5 per email address from any IP; throttled requests get the same neutral response | per hour |
| Current-password re-entry (change password or e-mail, delete account, forced change) | 5 per user; failures also count toward account lockout | per 15 minutes |
MFA challenge (hosted page and /mfa/challenges API, shared per-user budget) | 10; the API answers 429 rate_limited with Retry-After on create and verify | per 15 minutes |
| Registration | 5 | per hour |
Resend verification e-mail (/verify-email/resend) | 3 per address and 20 per IP (fixed) | per hour |
| Webhook deliveries | 100 | per 60 seconds |
| Push auth (initiate / respond / activation) | strict per-flow ceilings | per window |
Login throttling and account lockout
The three login rows above share one budget, the login_attempts limiter, keyed separately by
client IP and by normalised e-mail address. Both keys are consumed on every attempt. After 5
failed attempts the account is also locked for 15 minutes (the same lockout that protects
the portal form login), and IPs with excessive failures are blocked temporarily.
The two hosted OAuth login endpoints are the ones your own login UI talks to when it uses the LumoAuth hosted login page or the JSON login. How a rejection looks depends on the endpoint:
| Endpoint | When throttled or locked |
|---|---|
/oauth/login/submit (form post) | Redirect back to the login page with the generic "Invalid credentials" message. The reason is not disclosed to the caller; it is recorded in the audit log as throttled_or_locked. |
/oauth/login/json | 429 Too Many Requests with the body {"status":"rate_limited"} |
The form logins (/login, /orgs/{orgId}/login) spend the per-email unit
atomically before the password is compared, so parallel guesses cannot all slip in under the
lockout count; a spent budget returns 429 with the locked-account page.
A successful login clears the rate-limiter windows for that IP and e-mail, so a user is not punished for earlier typos.
Per-credential limit
Per-IP limits cannot stop one credential spread across many addresses. So the programmatic management and decision surfaces also count requests per credential:
| Surface | Paths |
|---|---|
| Admin API | /orgs/{orgId}/api/v1/admin/** |
| Authorization decision point | /api/v1/authz/** |
| Zanzibar expand | /orgs/{orgId}/api/v1/zanzibar/expand |
| ABAC bulk checks | /orgs/{orgId}/api/v1/abac/check-bulk |
| Self-service API | /orgs/{orgId}/api/v1/me and /me/** |
The key is the API key (by id), the access token's user plus client_id, or the signed-in
user for browser sessions. The limit is a sliding window of 600 requests per 60 seconds.
When it is spent the request is refused before any other check runs:
HTTP/1.1 429 Too Many Requests
Retry-After: 12
Content-Type: application/json
{"error": "rate_limited", "message": "Too many requests for this credential", "status": 429}
Spread batch jobs over time, or give independent jobs their own API keys (each key has its own budget).
Handling 429
{
"error": "rate_limit_exceeded",
"message": "Too many requests. Please retry after 30 seconds.",
"status": 429,
"retryAfter": 30
}
Implement exponential back-off with jitter when handling 429 responses:
async function requestWithRetry(fn, maxRetries = 5) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const res = await fn();
if (res.status !== 429) return res;
const retryAfter = parseInt(res.headers.get("Retry-After") ?? "1", 10);
const jitter = Math.random() * 1000;
await new Promise((r) => setTimeout(r, retryAfter * 1000 + jitter));
}
throw new Error("Max retries exceeded");
}
For bulk provisioning (e.g. importing large user sets), use the SCIM Bulk endpoint to reduce the number of individual API calls by combining multiple operations in a single request.