The 401 loop: when discovery and audience binding disagree
An MCP server that checks audience properly refuses every token that was not minted for it. That makes it safe and unreachable in a single move: a client has to learn what "for it" means before it can ask for anything. All it has to go on is one discovery document and the path that document is served at. Get a field in there wrong and nothing fails loudly. The client fetches a token for whatever the document named, the server refuses it as the wrong audience, the client fetches another one that fails the same way, and the loop lands in the client's logs looking like a client bug.
I keep the resource-server half of MCP authorization in a small Go package, mcpaudience. What it leads with is audience binding, because that is the rule that stops an MCP server from becoming somebody else's deputy. But the strictness is the cheap half. Discovery is the half that decides whether a strict server is usable at all, and it is where the bugs live, because every one of its failures is quiet.
The loop, and what each field in it carries#
Four hops, and a client that follows the specification makes all of them without asking a human anything:
- A request arrives with no token. The server answers
401and points at a document. - The client fetches that URL and gets back the protected resource metadata document of RFC 9728.
- The document names a
resourceand a list ofauthorization_servers. The client asks one of those issuers for a token whose audience is that exact resource. - The client retries with the token, and the server checks the audience against its own canonical URI.
Hop one looks like this:
--- no token ---
status: 401
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"Now look at what a wrong value does at each hop. A resource that is not the string the verifier binds to produces tokens minted for something this server does not answer to, a permanent invalid_token where the token is perfectly valid and the server is perfectly correct to refuse it. An issuer the server does not trust produces tokens signed by keys it does not hold. A document served at the wrong path produces a 404 at hop two, which leaves the client holding a challenge it cannot follow. And a bearer_methods_supported entry other than header invites the client to put the token in a query string, which is how a bearer token ends up in every access log between the client and here.
None of that returns an error saying "your metadata is wrong". That is the whole problem, and the reason the document deserves more care than a hand-written JSON literal.
The wiring is small:
guard := &mcpaudience.Guard{
Metadata: mcpaudience.NewMetadata("https://mcp.example.com", "https://auth.example.com").
WithScopes("files:read", "files:delete"),
Verifier: &mcpaudience.JWTVerifier{
Resource: "https://mcp.example.com",
Issuers: []string{"https://auth.example.com"},
Keys: publicKeysByKeyID,
},
}
mux := http.NewServeMux()
guard.Metadata.Mount(mux) // /.well-known/oauth-protected-resource
mux.Handle("/", guard.Handler(tools))NewMetadata sets BearerMethods to header and nothing else, because that default is not a preference: it is the one method that keeps the token out of logs and browser history.
A document the server would reject is never published#
Validate is the gate, and it refuses a document a client could not act on:
// Validate refuses a document a client could not act on.
func (m Metadata) Validate() error {
if err := canonicalURI(m.Resource); err != nil {
return fmt.Errorf("resource: %w", err)
}
if len(m.AuthorizationServers) == 0 {
return fmt.Errorf("authorization_servers: at least one issuer is required")
}
for _, issuer := range m.AuthorizationServers {
if err := canonicalURI(issuer); err != nil {
return fmt.Errorf("authorization_servers: %w", err)
}
}
for _, method := range m.BearerMethods {
// The other two methods RFC 6750 allows put the token in a query
// string or a form body. Both leak it into logs.
if method != "header" {
return fmt.Errorf("bearer_methods_supported: %q puts the token where it will be logged", method)
}
}
return nil
}The same canonical-URI check runs over the resource and over every issuer, and it asks four things of each: non-empty, absolute, https unless the host is localhost or 127.0.0.1, and carrying neither a fragment nor a query string. Each of those maps to a concrete way the loop breaks. A relative URI cannot be an audience value, because on the client's side there is no base to resolve it against. A fragment or a query string makes the identifier unstable under comparison: the audience check works on canonical form, and the more syntax an identifier carries, the more ways two spellings of the same server stop matching. Plain http puts the token on the wire in clear text, and the localhost carve-out exists only to permit that while you are developing. Zero issuers points the client at exactly one place for a token: nowhere...
Validation happens once, when the handler is built. If it fails, every request to the well-known path answers 500 with the validation message. That is deliberate. A malformed document is better withheld than served, because a served-but-wrong document produces the silent retry loop, while a 500 with the reason in the body produces a stack trace in the one place that can fix it. On success the body is marshaled once and served with cache-control: public, max-age=3600; methods other than GET and HEAD get 405 with an allow header.
The well-known segment goes in front of the path, not after it#
This is the detail that catches people, and I think it costs an afternoon when it does:
// WellKnownPath is the path the document has to be served at, per RFC 9728
// §3.1: the well-known segment goes between the origin and the resource's path,
// not after it. A server at https://mcp.example.com/mcp publishes at
// /.well-known/oauth-protected-resource/mcp, and appending instead would leave
// clients fetching a URL nothing serves.
func (m Metadata) WellKnownPath() string {
parsed, err := url.Parse(m.Resource)
if err != nil {
return MetadataPath
}
return MetadataPath + strings.TrimSuffix(parsed.Path, "/")
}An MCP server mounted at /mcp (which is most of them, since the transport wants one endpoint) does not publish at /mcp/.well-known/oauth-protected-resource. It publishes at /.well-known/oauth-protected-resource/mcp. Insertion, not suffixing.
The reason this is a function rather than a constant is that two places need the same answer and they must not drift: Mount registers the handler at WellKnownPath, and URL builds the absolute form that goes into the resource_metadata parameter of the challenge. Derive both from the resource and the 401 cannot promise a location the server does not serve. Write either one by hand and eventually it will.
Checked against the verifier, on every request#
There are now two independent copies of the same string in the configuration: the metadata's Resource, which clients act on, and the verifier's Resource, which tokens are checked against. Nothing structural keeps them equal. So the guard checks, before it reads any token at all:
if err := g.checkBinding(); err != nil {
http.Error(w, "mcpaudience: "+err.Error(), http.StatusInternalServerError)
return
}
token, err := BearerToken(r)And the check itself:
func (g *Guard) checkBinding() error {
binder, ok := g.Verifier.(ResourceBinder)
if !ok {
return nil
}
bound := binder.BoundResource()
if strings.TrimSpace(bound) == "" {
return ErrNoResource
}
if advertised := g.Metadata.Resource; advertised != "" && !sameResource(advertised, bound) {
return fmt.Errorf("the metadata advertises %q but tokens are bound to %q", advertised, bound)
}
return nil
}Two things here matter more than the code does.
The first is the status. Both failures answer 500, not a challenge. A 401 would hand the client a document to go fetch, and a client that followed it would come back with a token for the advertised resource that this server then rejects: the loop again, now manufactured by the server's own error handling. ErrNoResource says it plainly: no resource identifier configured: audience binding cannot be skipped. An empty resource is not a permissive mode. It is a server that cannot do the one job it exists to do, and blaming the caller for it wastes everyone's time.
The second is the comparison. sameResource works on canonical form: scheme and host case-folded, a default port dropped, a trailing slash dropped, and everything else exact. So HTTPS://MCP.Example.com:443 and https://mcp.example.com are the same server, which they are by every rule the URI syntax has. A prefix match or a host-suffix match, the shortcuts that suggest themselves first, would accept a token minted for a neighbour. The mismatch message names both values instead of saying "mismatch", because the whole difficulty of this class of bug is that the two strings look identical until you put them side by side.
What I would change#
Two things, and they are the same thing seen twice.
checkBinding opens with a type assertion, and a Verifier that does not implement ResourceBinder returns nil: no check performed, silently. For the built-in JWT verifier that is fine. For a custom verifier it means the one guard against advertised-versus-bound drift quietly does not exist, which is the exact failure mode the function was written to prevent. Folding BoundResource into Verifier itself would close that, at the cost of making every verifier declare a resource.
And both the metadata validation and the binding check surface on the first request rather than at boot. Guard is a struct with exported fields, so a composite literal is the construction, and there is no constructor to hang a check on. That is pleasant to write, and it means a misconfigured server deploys green and starts answering 500 only once a client shows up. If I were doing it again I would give Mount an error return and check the binding there, so a bad document fails the deploy instead of the first user.
The lesson I would keep from this is narrow and reusable. When a protocol has the server publish the terms a client must satisfy, those terms are code, and they belong under the same scrutiny as the check they feed. A refusal is easy to get right. A refusal that nobody can satisfy is also a refusal, and it will be read as the client's fault for as long as it takes someone to compare two strings by hand.