Skip to content

Pitfall invalid_scope disguised as 302

Jm Rohmer edited this page Jul 5, 2026 · 3 revisions

Pitfall: invalid_scope disguised as a 302 redirect

Affects: OAuth authorization flow
Discovered: v1.2.9 (2026-07-05)
Fixed in: v1.2.10


What happened

After fixing the wrong redirect URI in v1.2.9, Claude.ai still showed "Authorization with the MCP server failed" — even though nginx logs showed a clean HTTP 302 from /authorize.

The nginx log line for a successful authorization and for a failed one are identical:

POST /authorize HTTP/1.1" 302

The difference is only in the Location header:

Outcome Location header
Success https://claude.ai/api/mcp/auth_callback?code=XXXX&state=...
Failure https://claude.ai/api/mcp/auth_callback?error=invalid_scope&state=...

Because the redirect goes to Claude.ai's domain, the server never sees the callback. The only observable symptom server-side is: Anthropic's IPs (160.79.106.34–39) call /authorize but never call /token.


Root cause

Claude.ai requested the full historical scope list regardless of what the server advertised:

scope=content.read+content.write+site.admin+system.admin

Before v1.2.10, the server still treated system.admin as a separate tier above site.admin. resolveClientScope picked system.admin, compared it against the client's registered max (site.admin), and returned invalid_scope — which HandleAuthorize then sent as a redirect to the client's callback URI with ?error=invalid_scope.

The client (Claude.ai) received the error, aborted the flow, and showed "Authorization failed". The /token endpoint was never called.


How to diagnose

If Claude.ai says "Authorization failed" and you see 302s from /authorize in nginx but no /token call from Anthropic IPs:

# Simulate the exact authorize request Claude.ai sends
curl -sk -o /dev/null -w '%{url_effective}' \
  "https://mcp.arleo.eu/authorize?response_type=code \
   &client_id=claude-admin \
   &redirect_uri=https://claude.ai/api/mcp/auth_callback \
   &scope=content.read+content.write+site.admin+system.admin \
   &state=test \
   &code_challenge=n4sk8wlI_n8S-GaUfbNTr6dL7X5enlRgJe27i8U6Bhg \
   &code_challenge_method=S256"

A broken response looks like:

https://claude.ai/api/mcp/auth_callback?error=invalid_scope&state=test

A working response looks like:

https://claude.ai/api/mcp/auth_callback?code=XXXX&state=test

Fix

Two changes in v1.2.10:

1. Scope clamping (RFC 6749 §3.3)

Instead of returning invalid_scope when the requested scope exceeds the client's max, the server now clamps to the client's maximum allowed scope and issues the token. RFC 6749 §3.3 explicitly permits the authorization server to grant a subset of the requested scope.

// Before: rejected with invalid_scope
if !allowedScope(scope, c.Scope) {
    return "", fmt.Errorf("invalid_scope")
}

// After: clamp to client's max
if !allowedScope(scope, c.Scope) {
    scope = c.Scope
}

2. Collapse the former separate admin tier into site.admin

system.admin is no longer a separate exposed tier. It was collapsed into site.admin, and incoming system.admin values are normalized to site.admin. The active scope hierarchy is:

Rank Scope OAuth
0 anonymous none
1 content.read required
2 content.write required
3 site.admin required

Lesson

Never trust HTTP 302 from /authorize as proof of a successful flow. Always inspect the Location header for ?error=. The smoke test probe_authorize should check for the absence of error= in the redirect location, not just the 302 status code.

Related issue: #121

Clone this wiki locally