# Allowlist OAuth redirect URIs by host, not full path

Web page: https://academy.memberpress.ai/courses/agent-discoverable-surfaces/l3/

Lesson 6 of 7 in [Agent-Discoverable Surfaces](https://academy.memberpress.ai/courses/agent-discoverable-surfaces/), 12 min.

Video: https://www.youtube.com/watch?v=0MxmtGgi-dg

**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:

```php
// 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:

```php
$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:

```php
// 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:

```python
# 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:

```php
$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:

```bash
# 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**

- ["Closing the Agent Discovery Loop"](https://sethshoultes.com/blog/closing-the-agent-discovery-loop.html) — 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.

Next: [GPT Actions vs MCP — different discovery topology, same goal](https://academy.memberpress.ai/courses/agent-discoverable-surfaces/l4/)
