Auth · Auth and rate limits for agent traffic
Allowlist OAuth redirect URIs by host, not full path
Hook
Claude.ai shipped a new connector flow on a Tuesday. The redirect path moved from /api/mcp/auth_callback to /api/mcp/oauth-redirect. Every existing connection broke. Server logs read invalid_redirect_uri. The user UI said "couldn't connect." Nothing in your codebase had changed.
Concept
Prerequisites: you finished l1 in this module, you have shipped a public OAuth /register endpoint, and you know what dynamic client registration (RFC 7591) is. This lesson teaches what to allowlist when the other end of the contract belongs to a team that ships without telling you.
Why pinning the path fails
A redirect URI is two things in one string. The host (claude.ai) is a stable name — like an airport code. You can verify it against a published list of partners. The path (/api/mcp/oauth-redirect) is internal routing — the gate assignment, which the airline rewrites when a new terminal opens. Pinning both is signing a contract you only own half of.
When you pin the exact path, you are guessing at a value the client team changes without notice. The first guess works. The next client deploy invalidates it. You learn from a support ticket.
Walk-through
The brittle shape from the original incident:
// register-controller.php (before)
$allowed_callback = '/api/mcp/auth_callback';
return $path === $allowed_callback;
The short-term fix extends the path list when a new path appears. It buys one release cycle. It does not fix the contract:
$allowed_callback = ['/api/mcp/auth_callback', '/api/mcp/oauth-redirect'];
return in_array($path, $allowed_callback, true);
The structural fix pins the host strictly and prefix-matches the path:
// register-controller.php (after)
if (in_array($host, ['claude.ai', 'claude.com'], true)) {
return str_starts_with($path, '/api/mcp/');
}
return false;
The Flask shape is the same three moves:
# routes/register.py
TRUSTED_HOSTS = {"claude.ai", "claude.com"}
if host in TRUSTED_HOSTS and path.startswith("/api/mcp/"):
return True
return False
Then expose a filter so the next AI client an operator encounters is a one-line config change, not a release:
$extra_hosts = apply_filters('mp_mcp_oauth_allowed_redirect_hosts', [], $uri);
if (!empty($extra_hosts) && in_array($host, $extra_hosts, true)) {
return str_starts_with($path, '/api/mcp/');
}
The Python equivalent is a registry the operator extends at boot — a JSON file, an env var, an entry-point hook. Same shape, different idiom. The host strings (claude.ai, claude.com) and the path prefix (/api/mcp/) are the 2026-04-30 snapshot. The principle survives churn; the strings are examples.
Reproduce the rejection from your laptop before a user does:
# scripts/probe-redirect.sh
curl -s -X POST https://yoursite.com/oauth/register \
-H "Content-Type: application/json" \
-d '{"client_name":"probe","redirect_uris":["https://claude.ai/api/mcp/some-future-path"]}' \
| jq .
# → with the structural fix, returns a client_id
# → without it, returns invalid_redirect_uri
Field signature: a Claude Desktop user sees step=start_error in the connector URL — same fragment l1 covers, different cause. If your rate limit is healthy, the rejection is the redirect URI. The probe above reproduces it.
If you get stuck
- You pinned the host but skipped the prefix check. Any path on
claude.ainow passes, including/adminor/billing. Addstr_starts_with($path, '/api/mcp/')and write the negative test that should fail. - You allowed a wildcard host.
*.claude.aimatchesevil-claude.ai.attacker.comunder most naive parsers. Strict equality on the host string, full stop. - You shipped the filter but never documented it. The next operator hits the same wall and patches with a code release anyway. One paragraph in your README pointing at
mp_mcp_oauth_allowed_redirect_hostssaves the cycle.
Cross-reference
Sibling lesson l1 covers the rate-limit shape on the same /register endpoint. Both failures surface as step=start_error — a manual POST to /register with the client's expected redirect URI is the disambiguator between "rate limit fired" and "redirect URI rejected."
Deliverable
Your /register endpoint accepts a registration whose redirect URI is https://claude.ai/api/mcp/<any-future-path> provided the host is in your strict list and the path is under your prefix. You can describe — without checking notes — why allowlisting on path alone is brittle for clients you do not control, and the one-line filter you would expose to an operator who finds a new client tomorrow.
Further reading
- "Closing the Agent Discovery Loop" — the conceptual framing for why a public OAuth surface looks different when the clients are AI platforms you do not own.
Try these prompts
- Rewrite an OAuth redirect-URI allowlist check so it strict-matches the host against a known partner list and prefix-matches the path, then add a filter hook that lets an operator extend the host list without a code release.
- Describe the diagnostic difference between a step=start_error caused by a /register rate limit and one caused by an invalid_redirect_uri rejection, and write the manual POST you would run from your laptop to disambiguate them.
- Name three failure modes a structural host-plus-prefix allowlist still has — over-permissive path acceptance, wildcard host accepting attacker subdomains, and an undocumented filter hook — and write a one-line guardrail for each.
Sign in to keep your place and tick off lessons. Reading stays open either way.