MemberPress AI Academy memberpress.ai

Auth · Auth and rate limits for agent traffic

Allowlist OAuth redirect URIs by host, not full path

Video12 min

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.ai now passes, including /admin or /billing. Add str_starts_with($path, '/api/mcp/') and write the negative test that should fail.
  • You allowed a wildcard host. *.claude.ai matches evil-claude.ai.attacker.com under 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_hosts saves 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

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.