When one person runs ten agents, a limit per account hands that person ten times the quota. Rate limiting per human fixes it by keying the limiter on the agent's owner, so every agent one person runs draws from the same allowance. You will see which identifier to key on, a small middleware example, and how long an agent's sign-in lasts before your app has to ask again.
To rate-limit AI agents per person instead of per account, key your limiter on the agent's owner, not on the account or the address. AgentID, the sign-in for AI agents from AgentMail, gives every signed-in agent an owner identifier that all of one person's agents share, so ten agents from one person draw from one bucket.
Rate limits were designed around a quiet assumption: one account is roughly one person. Free tiers, trial credits and API quotas all lean on it. Agents break the assumption because they are cheap to multiply, and a person who can create an inbox in one call can create ten. The fix is small once you have the right key, and most of the work is choosing which identifier to trust.
Why do per-account rate limits fail once agents sign up?
Per-account limits fail because an agent fleet turns one customer into many accounts. Agents are already signing up for software, often as their owners, which can an AI agent sign up for a SaaS covers from the product side. Once they sign up as themselves, each one is a fresh account with a fresh allowance.
Per-IP limits don't rescue this. Agents often run on shared cloud infrastructure, so one address can carry many unrelated customers while one customer's agents spread across many addresses. The limit ends up punishing neighbors and missing the person you meant to slow down.
What you want is the unit your pricing already assumes: the human who pays, or would pay. That is the owner, and keying the limit to the owner makes a free tier mean one free tier per person again, however many agents that person runs.
Which claim should your limiter key on?
Key the limiter on owner_sub when it is present and fall back to sub when it isn't. Here is what each option does when one person runs ten agents against your app.
| Key | One person, ten agents | Available to |
|---|---|---|
| IP address | Unpredictable; shared by strangers, spread across hosts | Any request |
| Your account ID | Ten accounts, ten quotas | Any app |
sub | Ten agents, ten quotas, each stable across apps | Every AgentID sign-in |
owner_sub | One owner, one quota | Apps that request the profile scope, open or registered |
owner_email | One owner, one quota, plus a way to reach them | Registered apps that request the owner_email scope |
owner_sub is an opaque 43-character identifier that agents with the same owner share across apps. It can't be matched back to AgentMail API ids, so it identifies the owner without telling you who they are, which makes it a safe thing to put in a limiter's keyspace. owner_email works too, but only registered apps receive it, and it is a real person's address sitting in your rate-limit store.
AgentID omits claims it didn't grant rather than sending them as null. An app that didn't request profile simply won't see owner_sub, so the limiter has to test for presence and fall back to the agent's own sub.
What does per-owner rate limiting look like in code?
Per-owner rate limiting is an ordinary token bucket with a better key. The TypeScript below is illustrative: it keeps buckets in memory to stay short, and the capacity and refill numbers are placeholders for your own. In production the buckets belong in a shared store such as Redis.
// Illustrative only. A token bucket keyed on the agent's owner.
type AgentClaims = { sub: string; actor_type: 'agent'; owner_sub?: string }
const CAPACITY = 60 // burst size, your number
const REFILL_PER_SEC = 1 // steady rate, your number
const buckets = new Map<string, { tokens: number; updated: number }>()
export function limiterKey(claims: AgentClaims): string {
// owner_sub comes with the profile scope. Ungranted claims are omitted,
// so an agent without one is limited as its own owner.
return claims.owner_sub ? `owner:${claims.owner_sub}` : `agent:${claims.sub}`
}
export function allow(claims: AgentClaims, now = Date.now()): boolean {
const key = limiterKey(claims)
const bucket = buckets.get(key) ?? { tokens: CAPACITY, updated: now }
const elapsed = (now - bucket.updated) / 1000
bucket.tokens = Math.min(CAPACITY, bucket.tokens + elapsed * REFILL_PER_SEC)
bucket.updated = now
const allowed = bucket.tokens >= 1
if (allowed) bucket.tokens -= 1
buckets.set(key, bucket)
return allowed
}
// Express-style middleware. The claims were saved to the session at sign-in.
app.use((req, res, next) => {
const agent = req.session.agent
if (agent === undefined) return next() // human users keep your existing limits
if (allow(agent)) return next()
res.status(429).set('Retry-After', '1').end()
})The owner: and agent: prefixes keep the two kinds of key from colliding. Because actor_type is always "agent" on an AgentID token, the same session record also tells you which requests belong in this limiter at all, so human traffic can keep the rules it has today.
Does AgentID rate-limit agents for you?
No. AgentID doesn't publish rate limits for its sign-in endpoints, and it doesn't meter your users on your behalf. The token is built to carry enough to attribute, rate-limit, allowlist and revoke, and the limiter that uses it lives in your app.
That split is deliberate. Only you know what a fair quota is for your product, whether a free tier gets five requests a minute or five thousand, and whether a paid owner's agents get more. AgentID's part is making the owner a stable, verifiable key, so the number you pick applies to a person rather than to however many accounts they could open.
The same owner key also caps signups, not just requests. How to stop fake agent signups without blocking real agents covers the console setting that limits accounts per owner before they exist.
How long does an agent's sign-in last, and how do you refresh it?
An agent's id_token and access token both last 10 minutes, and there are no refresh tokens. After that your app runs a new sign-in. Within the 180-day remembered approval for that inbox and app, the new sign-in completes without the agent doing anything, so a periodic re-check costs the agent nothing.
Between sign-ins, your app's own session carries the agent, and you choose how long it lasts. Inside the 10-minute window, /v0/userinfo re-checks live state on every call, which means a revoked key or inbox shows up there before the token expires. A short session plus a quiet re-sign-in gives you fresh proof without building refresh logic that AgentID doesn't offer.
None of this disturbs the limiter, and none of it needs a background job to keep tokens alive. owner_sub stays the same across sign-ins, so an agent that signs in again lands in the same bucket it left. The full list of lifetimes and claims is in the AgentID docs, and agent builders can see the owner's side of this on the AgentID page on AgentMail.
AgentID gives your agent a verified identity and its own email address. Free for apps to add.

