Auth · Auth and rate limits for agent traffic
Rate-limit OAuth /register for AI clients, not human OAuth
Hook
The server logs say "rate limit working as designed." The user's connector UI says "couldn't connect." Nothing in between names the cause. The rate limiter on your /register endpoint was tuned for one developer testing one app — not for thousands of users sharing one AI client's egress IP.
Concept
Prerequisites: you have shipped a public HTTP endpoint, you know what OAuth dynamic client registration (RFC 7591) is, you have read the agent-discovery-loop lesson in api-fundamentals, and you have written a naive per-IP rate limiter. This lesson teaches how to recalibrate one for a traffic shape you have not seen yet. /register here is the OAuth dynamic-client-registration endpoint, where a new client introduces itself before it has credentials. Not your authenticated API.
Why human OAuth and AI OAuth look the same on paper but break in opposite directions
An apartment building has one front door. Human OAuth is the resident model: you move in, get a key, and never queue at the door again. The lock can be slow, the lobby small — none of it matters. The resident passes through once and reuses the key for years.
AI OAuth is the visitor model. The AI client does not keep keys. Claude.ai re-registers an OAuth client on every connection attempt — no client_id persists across user sessions. Claude.ai also routes a whole region through a small set of egress IPs, so every visitor knocks on the same door, from the same address, every time they want in. The door is the lobby is the queue. A 5-per-hour-per-IP limit sized for one resident-per-year evaporates by lunch.
Walk-through
The tutorial default you copied has two flaws: a 5/hour threshold (a residential lobby, not a public turnstile) and a 429 with no Retry-After and no actionable message. The AI client retry-storms; the user sees a generic failure. The fix raises the threshold and makes the 429 carry useful information:
// register-controller.php (after)
private function check_registration_rate_limit(): ?WP_REST_Response
{
$ip = $this->get_client_ip();
$key = 'mp_mcp_register_' . md5($ip);
$attempts = (int) get_transient($key);
if ($attempts >= 60) {
return new WP_REST_Response(
[
'error' => 'too_many_requests',
'error_description' => 'Too many client registrations from this IP. Please try again in an hour.',
],
429,
['Retry-After' => '3600']
);
}
set_transient($key, $attempts + 1, HOUR_IN_SECONDS);
return null;
}
The Flask shape is the same three moves:
# routes/register.py
if attempts >= 60:
return jsonify(
error="too_many_requests",
error_description="Too many client registrations from this IP. Please try again in an hour.",
), 429, {"Retry-After": "3600"}
60/hour is roughly one per minute — still abuse-resistant (an attacker can't fingerprint your tenant boundary or exhaust resources at that rate), but it absorbs dozens of legitimate AI users behind one egress IP. As of 2026-04-30 this is the working number for Claude.ai connector traffic. Treat it as a current example, not a ceiling. Watch your 429 rate after deploy and raise the floor if real users still hit it.
Reproduce the failure locally before users do:
# scripts/probe-register.sh
for i in {1..70}; do
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST https://yoursite.com/oauth/register \
-H "Content-Type: application/json" \
-d '{"client_name":"probe","redirect_uris":["http://localhost"]}'
done
# → 200 for the first batch, then 429 once you cross the threshold
If the 429 arrives without a Retry-After or with a generic "Try again later" body, the threshold is fine but the diagnostic is invisible — the silent-failure shape survived the bump.
Field signature: when a Claude Desktop user reports "couldn't connect," ask for the connector URL and look for step=start_error. That fragment means /register failed before login could start. The probe above reproduces the same shape from your laptop.
If you get stuck
- The limit lives in a layer you do not control (CDN, WAF, hosting plan). Your application code looks correct; the 429 is emitted upstream with no
Retry-After. Find the layer, move the limit into your app, or have the upstream provider widen it for/register. - You raised the threshold but the 429 still has no
Retry-After. AI clients without that header retry on their own schedule, which compounds the load. Add the header. One line. - Your error message is generic, so the user can't self-diagnose. "Try again later" tells them nothing. Replace with a specific window — "Try again in an hour" — and AI client surfaces pass it through to the user where it does some good.
Cross-reference
The agent-discovery-loop lesson in api-fundamentals (l9) is the public-MCP plus .well-known/mcp.json pattern this auth surface lives behind. Every endpoint in that discovery file shares this calibration problem: any per-IP limit on a path AI clients touch during connection setup needs to be sized for shared egress, not single-developer traffic.
Deliverable
Your /register endpoint accepts at least 60 registrations per IP per hour. On overflow it returns 429, a Retry-After: 3600 header, and an error_description naming the wait time in concrete units. You can describe — without checking notes — the diagnostic fragment in a Claude Desktop connector URL that tells you a /register rate limit fired before login could start.
Further reading
- "Closing the Agent Discovery Loop" — the conceptual framing for why an AI agent's traffic shape differs from a human's, and what the public surface needs to look like.
Try these prompts
- Rewrite a check_registration_rate_limit() function so the threshold is 60/hour and the 429 response includes a Retry-After header and an actionable error_description.
- Describe the diagnostic signature in Claude Desktop that tells you a /register rate limit fired before login could even start, and the manual POST that reproduces it.
- List three endpoints in an MCP or OAuth flow that AI clients hit during connection setup and explain why per-IP rate limits there need calibration for shared egress, not human single-user traffic.
Sign in to keep your place and tick off lessons. Reading stays open either way.