Limits & quotas
ReadySeats come from your license and are shared out between your relying parties; a relying party at its share is refused new users with MAX_USERS_REACHED, and a deployment holding more than its license allows refuses calls with LICENSE_USER_CAP_REACHED. Per-user rate limits bound what can be done to any single user, as five token buckets in three tiers; empty one and that user is refused that action for 30 minutes, or longer if a whole try takes longer to earn back. A new challenge for a user is refused within 15 seconds of their newest open one, and an approval gives the challenge tries back. A user’s codes lock after 100 wrong codes in a row, until you issue a new secret. Each phone connection gets 30 calls at once, then one every 2 seconds. Enrolment has a budget per call and server instance, shared by every phone: 1,000 calls at once, then 100 a second. Nothing limits a relying party’s calls as a whole. Most limits are generous and all fail closed. The per-user tier, a challenge’s lifetime and certificate lifetimes are yours to set; the rest is fixed.
Who this is for: anyone sizing a deployment or making sense of a LICENSE_USER_CAP_REACHED or RATE_LIMIT_EXCEEDED error. Here are the concrete numbers, and which are hard versus tunable.
Kinds of limit#
Two are hard caps you can reach — one bounds the whole deployment, one bounds a single identity; they come from different places and fail in different ways.
How many users you may create. The number comes from your license, shared out between your relying parties. Reaching a relying party’s share is a fail-closed refusal, MAX_USERS_REACHED, not a throttle; a deployment holding more than its license allows refuses calls with LICENSE_USER_CAP_REACHED.
How much can be done to any single user through sensitive actions, enforced as token buckets on the server. Empty a bucket and that one user is refused that action for 30 minutes or more (RATE_LIMIT_EXCEEDED); everyone else, and the user’s other actions, are unaffected.
Plan caps#
Set by your license. A free license’s values are fixed; paid plans raise them.
| Cap | Free license | Paid | Reached → |
|---|---|---|---|
| Users (opaque IDs) | 50 | your plan’s seats | LICENSE_USER_CAP_REACHED |
Your license carries a single seat total, and you split it across your relying parties however you like — the only rule is that the sum of every RP’s reservation stays within that total. One RP can use the whole budget; adding an RP partitions your budget, it does not add to it. A reservation can always be cut, down to the users the RP holds, even when a license with fewer seats has put the total over; only a raise has to fit (see Renewing with fewer seats). The pricing bands that set your seat total live on Pricing and Get a license.
Per-user rate limits#
Five actions have a token bucket per user: a burst of tries, and one try earned back every so often. Spend faster than they come back and the bucket empties. That action is then refused for 30 minutes, or until a whole try has been earned back if that takes longer; the time waited counts toward the refill. Each refusal is RATE_LIMIT_EXCEEDED with an auto_expires_at: the time the call is let through again. Do not retry before it.
Three tiers are selectable per relying party, trading user friction against per-user attack surface. The buckets, tier by tier:
Tighter — for finance, healthcare, and regulated verticals, where bounding per-user attack surface outweighs a little user friction.
| Action | Burst | Refills |
|---|---|---|
| Verify a TOTP code | 5 | +1 / 30 min |
| Issue a TOTP secret | 10 | +1 / 1 h |
| Issue a challenge | 3 | +1 / 24 h |
| Issue an enrollment invite | 3 | +1 / 4 h |
| Issue a recovery invite | 2 | +1 / 24 h |
The default — fits about 80% of applications.
| Action | Burst | Refills |
|---|---|---|
| Verify a TOTP code | 5 | +1 / 15 min |
| Issue a TOTP secret | 10 | +1 / 1 h |
| Issue a challenge | 10 | +1 / 4 h |
| Issue an enrollment invite | 5 | +1 / 1 h |
| Issue a recovery invite | 2 | +1 / 6 h |
Looser — for chatty consumer apps with frequent re-auth, where session continuity outweighs the marginal security cost.
| Action | Burst | Refills |
|---|---|---|
| Verify a TOTP code | 10 | +1 / 5 min |
| Issue a TOTP secret | 10 | +1 / 1 h |
| Issue a challenge | 20 | +1 / 1 h |
| Issue an enrollment invite | 10 | +1 / 30 min |
| Issue a recovery invite | 5 | +1 / 2 h |
What spends a try:
- Verify a TOTP code (VerifyUserTotp): a wrong code spends one, and so does a refusal (a user you locked, a user with no secret). A correct code spends nothing. Each code is accepted once: a code already accepted, or one older than the last accepted, counts as a wrong one.
- Issue a TOTP secret (IssueUserTotpEnrollment): every call.
- Issue a challenge (IssueAuthChallenge, push or QR): every call, a retry with the same challenge ID included. A refusal for the gap below spends nothing, and an approval gives the tries back.
- Issue an enrollment invite and Issue a recovery invite: every call. Recovery has its own, tighter bucket because a recovery invite revokes every device the user has.
A call answered NOT_FOUND spends nothing: a user that does not exist, or a challenge for a user with no active device (NO_ACTIVE_DEVICE_FOR_USER). Nor does a call that fails on the server’s side.
A user you lock yourself with UpdateUserStatus is a different state, USER_LOCKED, which lasts until you unlock them. Your own calls about a user (GetUser, UpdateUserStatus, watching a challenge) are not limited.
15 seconds between challenges
While a user’s newest challenge is still open and less than 15 seconds old, IssueAuthChallenge for that user is refused RESOURCE_EXHAUSTED with reason CHALLENGE_TOO_SOON, and auto_expires_at set to when the next one is let through. No try is spent. Once the user answers the open challenge, or it expires, the next is let through at once. A retry with the open challenge’s own ID is not refused for the gap: it answers ALREADY_EXISTS, and spends a try. Older challenges stay open on the phone until they are answered or expire. The 15 seconds are fixed.
An approval gives the tries back
When the user approves a challenge, that user’s challenge bucket is full again. A user who signs in often is never refused for it. It also means someone who has the user’s password gets the full burst back each time the user approves one of their prompts. How many approvals you accept before you ask for something more is your sign-in page’s decision; the server does not limit it. A denial, a dismissal, a wrong number or an expiry gives nothing back, and an approval leaves the TOTP bucket as it is.
With several server instances, each holds a refusal it gave in memory for 5 seconds (30 for a recovery invite). After an approval or an unlock, another instance can keep refusing until its memory runs out, naming the old auto_expires_at. If a call is refused within seconds of an approval or an unlock, retry after 5 seconds (30 for a recovery invite).
Clearing a user’s limits
The relying party can clear a user’s buckets early with UnlockUserRequestLimits: for one user, 3 clears at once, then one more every 10 minutes (about 6 an hour); once they are used up, none for 30 minutes. An operator can clear them with ydfi admin reset-quotas, which has no such limit; a refusal the identity server already holds in memory can last up to 5 more minutes. Neither unlocks a user you locked (USER_LOCKED) or locked codes (below).
The codes lock#
After 100 wrong codes in a row, a user’s TOTP codes lock. The 100th wrong code is checked and spends a try like any other, and is refused FAILED_PRECONDITION with reason TOTP_LOCKED. Every code after it, a correct one too, gets the same refusal without being checked, and spends no try. The 100th code usually empties the TOTP bucket, so for the next 30 minutes or so the answer is RATE_LIMIT_EXCEEDED first; GetUser shows the lock throughout. A correct code before the 100th starts the count again.
- Only codes lock. The user stays
ACTIVE, and push and QR challenges still work. - You can see it: GetUser and ExportUsers carry
totp_locked. The server also tells the user’s phones. - Only a new secret unlocks the codes:
IssueUserTotpEnrollment(reuse_existing=false). Reusing the secret, changing the user’s status, or clearing their limits does not.
The TOTP bucket slows the guessing first: at the Standard tier, 100 wrong codes take about a day. The 100 is fixed.
Devices & phones#
| Limit | Value | Reached → |
|---|---|---|
| Active devices per user | 3 | MAX_DEVICES_REACHED |
| Calls per phone connection | 30 at once, then 1 / 2 s | RATE_LIMIT_EXCEEDED |
| Challenge streams per user, per server instance | 5 | RATE_LIMIT_EXCEEDED |
| Challenge streams per device, per server instance | 2 | the oldest ends |
The device limit is checked when you issue an enrollment invite and again when the device enrolls, see Enroll a device. The call budget is counted per connection before the device is known, then a second budget of the same size per device, shared by every connection it opens, so reconnecting gains nothing. A phone opens one connection per account and makes a few calls when the app opens and a few per approval, far inside it. The phone limits are fixed.
Enrolment budget#
A phone that has not enrolled yet has no identity to count against. So each of the two enrolment calls, BeginEnrollment and CompleteEnrollment, has one budget per server instance, shared by every caller: 1,000 calls at once, then 100 a second. It is checked before anything else and held in memory, so a refused call never reaches the database.
It is not counted per address. An office enrolling from behind one NAT, or behind a load balancer that hides each phone’s address, is never refused for sharing it. A 1,000-person office enrolling within the hour, or 10,000 people within ten minutes, stays well inside the budget.
A refused call gets RATE_LIMIT_EXCEEDED with the message enrolment is busy on this server; retry in a few seconds. The iOS app sends a refused call again after 0.5, 1, 2 and 4 seconds, then tells the user the service is busy. Because the budget is shared, a client that sends more than it refuses enrolment for everyone on that instance while it keeps sending. The server logs a warning at most once a minute while it refuses, with a sample caller address, so you can block the source at your edge; behind a load balancer that does not keep client addresses, that address is the balancer’s, and the source is in its logs. The budget is fixed.
What protects each invite is the invite itself: it is 32 random bytes, it cannot be guessed, and after three claim attempts it is revoked.
Lifetimes & TTLs#
How long the time-boxed things live. Certificates are covered in full on Certificates & PKI.
| What | Default | Notes |
|---|---|---|
| Authentication challenge | 60 s | set per call via ttl_seconds; server-enforced |
| Enrollment / recovery invite | 5 min | single-use |
| Device certificate | 1 year | auto-renews monthly via RenewCertificate; a device that stops checking in lapses on its own |
| Relying-party certificate | 2 years | up to 5 years; manual RotateCertificate |
| Server & admin leaf | 90 days | operator-managed |
| Root CA | 10 years | rotate via ydfi pki |
Hard versus tunable#
The seat count, the enrolment budget, the phone call budget, the 30-minute lockout, the 15-second gap, the codes lock at 100, the device limit and the mutual-TLS requirement cannot be exceeded from a client. A challenge’s expiry is enforced server-side no matter what a caller claims.
The per-user rate tier (per relying party), a challenge’s ttl_seconds, and certificate lifetimes are yours to set. The relying party can clear a user’s buckets 3 times at once, then about 6 times an hour; an operator, without limit.
These limits live in the server, not the client — a modified app cannot lift its own quota. That is the point: the limits are part of the security model, not a convenience. See Errors for the typed reasons each one raises.