The Four Checks Most MCP Servers Skip
Overview
Astrix Security's "State of MCP Server Security 2025" report analyzed over 5,200 open-source MCP server implementations and found that only 8.5% used OAuth at all. That's despite MCP servers having been OAuth 2.1 resource servers since the spec's June 2025 revision. The spec kept moving after that. The July 28, 2026 revision hardened authorization further, adding RFC 9207 issuer validation among other things. The implementations mostly didn't move with it.
Underneath all of this sits the same unglamorous question. Is the thing calling this tool who it says it is, and is it allowed to call it? I'd built polytoken a month earlier to get back into Go and understand that question at the JWT level. That meant multi-issuer validation, HS256 and RS256, JWKS rotation, everything Spring Security had been quietly handling for me for years. I had a JWT library sitting around and a spec that needed most of the primitives it already handled, plus a few it didn't. polytoken-mcp is what came out. Four checks on the way in, one guardrail on the way out. All things the spec calls out and most implementations skip.
Architecture
The Shape of the Problem
A request is sent to your MCP server. Before your handler ever runs, four things must be true:
- A bearer token must be present and well-formed.
- The token is cryptographically valid (real signature, not expired, delegated to polytoken).
- The token was issued for this specific server (resource indicator, RFC 8707).
- The token came from the authorization server this client was supposed to use (issuer binding, RFC 9207).
That covers a request coming in. A separate rule governs requests going out. If your server ever calls another API on the client's behalf, it can't just hand over the token it was given. That is a different problem, and it gets its own section later.
None of the four checks above can happen, though, until the client of your MCP server knows how to get a token in the first place. That's where discovery comes in.
Discovery
Protected Resource Metadata (RFC 9728) is an OAuth 2.0 specification
published in April 2025 that allows an API or resource server to describe its security needs, scopes, and valid
authorization servers in a standard JSON format. A client hits a well-known URL .well-known/oauth-protected-resource
and receives enough information to authenticate, without any prior coordination with the server it's talking to.
For polytoken-mcp, that document looks like this:
{
"resource": "https://mcp.example.com",
"authorization_servers": [
"https://auth.example.com"
],
"bearer_methods_supported": [
"header"
]
}
Only resource is REQUIRED by the spec, authorization_servers is OPTIONAL. In practice, a Protected Resource Metadata
document that doesn't list any authorization servers isn't much use to a client, so discovery.NewHandler,
the handler I implemented to serve this document, treats both as required constructor arguments. Stricter than the spec
demands, but the looser version isn't useful enough to justify supporting it.
One field I don't populate is scopes_supported, also OPTIONAL. That's not an oversight so much as an admission.
polytoken-mcp doesn't enforce scopes anywhere yet, and advertising scopes I don't check felt worse than leaving the
field out. Given that this post is partly about implementations skipping what the spec calls out, it seemed dishonest
not to flag my own version of that.
The one design decision worth calling out is that the discovery endpoint has no authentication. A client that doesn't have a token yet can't be expected to authenticate its way into finding out how to authenticate.
Token Validation
I already did the hard part with polytoken. This file is deliberately thin. It delegates the actual cryptography
entirely and only handles the HTTP-layer plumbing, pulling a bearer token from the request and passing it to polytoken's
Resolver to validate.
Extraction has three distinct failure modes, not one.
- No
Authorizationheader at all - A header that isn't prefixed with
Bearer Bearerprefix with nothing following
I kept them as three errors rather than one generic unauthorized, mostly so the tests could tell them apart.
Once a token is verified to be present, this file's job is mostly done; it hands the raw string straight to the
Resolver published by polytoken, which is the piece that actually knows how to route it to the right validator,
check the signature, and confirm it hasn't expired. The one thing it does beyond that is stash the raw token
alongside the returned context, via WithToken, so it survives past this function without going back through
extraction again. That's the token passthrough.Guard reads later.
func (v *Validator) Validate(ctx context.Context, r *http.Request) (context.Context, *principal.Principal, error) {
token, err := extractBearerToken(r)
if err != nil {
return ctx, nil, err
}
p, err := v.resolver.Validate(ctx, token)
if err != nil {
return ctx, nil, err
}
return WithToken(ctx, token), p, nil
}
This is the one file in the project where the connection between the two repos shows up in an import statement rather than a README.
Resource Indicators
A token can be perfectly signed, unexpired, and issued by an authorization server you genuinely trust, and still be the wrong token. RFC 8707 exists because none of that says anything about which resource server the token was actually meant for. Without a resource indicator, a token issued for one MCP server could be replayed against a completely different one. Every check up to that point would pass, because none of those checks were ever asking "was this token for me."
CheckResourceIndicator reads the token's aud claim and confirms it matches this server's own canonical URI.
Per the spec, aud can be a single string or a JSON array of strings. A token can legitimately be valid for more than
one resource at once. That means Principal.Claims["aud"] isn't one type. It's two. And Go's encoding/json doesn't
make this easier than it needs to be. It comes out as []interface{}. Coming from Java, I expected the decoder
to do some magic for me. Give Jackson a target type, and it hands back a List<String>. encoding/json decoding into
an untyped map has no target type to work from, so it doesn't coerce anything. Worse, the failure is quiet. Java would
throw a ClassCastException at me. Go's type assertion just returns false and falls through.
switch aud := p.Claims["aud"].(type) {
case string:
// single audience
case []interface{}:
// multiple audiences, still not []string
default:
// missing or malformed
}
My first pass at the array case:
case []interface{}:
for _, a := range aud {
if audStr, ok := a.(string); ok {
if !strings.EqualFold(audStr, expectedResource) {
return ErrResourceMismatch
}
}
}
This bails on the first entry that isn't the expected resource, which in a multi-element array is usually the first
iteration. A token valid for both mcp.example.com and other.example.com fails against mcp.example.com because the
array also contained something else. The whole reason aud is allowed to be a list is that
a token can be valid for more than one resource.
There's a second problem in the same block. If the loop runs to completion without hitting a mismatch, nothing returns
at all, so an aud array containing no strings falls straight through and passes. A check whose entire job is
preventing replay, failing open. Both problems come out of the same fix.
case []interface{}:
matched := false
for _, a := range aud {
if audStr, ok := a.(string); ok && strings.EqualFold(audStr, expectedResource) {
matched = true
break
}
}
if !matched {
return ErrResourceMismatch
}
Single-audience tokens pass both versions, which is exactly why neither problem showed up at first. Single-audience tokens were all I had in my test table, and a test table that never exercises the case a check exists for will happily watch that check do nothing. The bug wasn't really in the loop. It was in what I'd bothered to test. Once I added a token with multiple audiences, both problems surfaced at once. The early-return broke legitimate multi-resource tokens, and the fall-through let a malformed array pass.
The second one should worry you more than the first. A resource-indicator check that wrongly rejects a valid token is annoying. It breaks a legitimate client, loudly, and someone files a bug. A resource-indicator check that wrongly accepts an invalid one fails silently, does nothing, and gives you no reason to ever look at it again. The whole job of that check is catching replay. A version that fails open doesn't just fail to do that job; it's worse than no check at all, because a missing check at least looks unfinished.
Issuer Binding
CheckIssuerBinding is the one I had to stop and think about. The first time I wrote it I wasn't sure it wasn't
redundant. Polytoken already checks iss. Every validator is built with jwt.WithIssuer, which confirms a token's
iss claim matches the issuer that validator was configured for. So why check it again here?
The answer is that the two checks are confirming different things, even though they are both reading the same
claim. Polytoken's check is cryptographic. Given a validator built for issuer X, does this token's iss claim say
X, and does the signature actually check out for X's key.
CheckIssuerBinding is contextual, not cryptographic. It asks a different question. Is this the authorization server
the client was supposed to be using for this resource? If your resolver trusts two issuers, say a customer identity
provider and an internal one, a token can be completely genuine, signed correctly, issued by an issuer polytoken
generally trusts, and still be the wrong token for this interaction. This is the authorization-server mix-up RFC 9207
exists to close.
In a single-issuer setup the two checks usually agree, which is why this looks like duplicate work. The moment you're trusting more than one issuer, which is the entire premise polytoken was built around, they stop being the same question.
func CheckIssuerBinding(p *principal.Principal, expectedIssuer string) error {
if p.Iss == "" {
return ErrMissingIssuer
}
if !strings.EqualFold(p.Iss, expectedIssuer) {
return ErrIssuerMismatch
}
return nil
}
The Passthrough Guard
Everything up to this point has been about deciding whether to let a request in. This part is different. It's about what your server is allowed to do once a request is already in.
Say your MCP server, in the course of handling a validated request, needs to call some other API on the client's behalf. A billing service, an internal tool, Jira, whatever. The naive thing to do is take the Authorization header off the incoming request and forward it straight through.
The problem is that a token scoped to your MCP server was never issued for that other service. If the upstream API doesn't check its audience as carefully as you just did, it might accept the token anyway, and now your server has tricked a completely unrelated system into trusting a client it never actually authenticated. Your server didn't mean to do anything malicious, it just forwarded a header.
This is what the MCP spec calls out specifically as token passthrough, and it names it separately from the
confused deputy problem, which is really about proxy servers with static client IDs skipping the consent screen.
They're not the same failure. But passthrough is a version of the same family. A system trusted for one thing gets
talked into using that trust for something else, without ever being compromised itself. passthrough.Guard is named
for the specific anti-pattern it guards against, not the broader class.
Every other check in this project is a function. Take some input, return an error, or don't. This one is a transport.
passthrough.Guard implements http.RoundTripper, so it sits in the path of outgoing requests rather than incoming
ones.
func (g *Guard) RoundTrip(req *http.Request) (*http.Response, error) {
inbound, ok := TokenFromContext(req.Context())
outbound := strings.TrimPrefix(req.Header.Get("Authorization"), "Bearer ")
if ok && outbound == inbound {
return nil, ErrTokenPassthrough
}
return g.roundTripper.RoundTrip(req)
}
If the outgoing token is byte-for-byte the same as the token the client sent in, the request never leaves. The comparison only runs when there's an inbound token in context to compare against. If there isn't one, the call goes through. The guard can only prove a request is unsafe when it has something to compare against, and plenty of outgoing calls have nothing to do with any client's token. Blocking those by default would cost real traffic to catch a problem that was never there.
Byte-equality is a cheap heuristic for the mistake people actually make, not a proof of safety. It catches the
copy-the-header-and-move-on reflex, which is the common one. It won't catch the same token moved into a query parameter
or a request body, and it won't catch a token exchanged for a different one that still carries the client's identity to
an upstream that isn't checking aud.
It also has a false positive I know about. A token can legitimately be valid for more than one resource. That's the
whole reason aud is allowed to be an array, and the reason the resource indicator check handles that case. If a client
deliberately got a token for both this server and the upstream, forwarding it is correct, and the guard refuses it
anyway. Narrowing that would mean an opt-out for specific requests.
Middleware
Every piece so far is a component. middleware.Guard is what turns them into something you can put in front of a
handler.
func (g *Guard) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, p, err := g.validator.Validate(r.Context(), r)
if err != nil {
challenge.Unauthorized(w, g.prmURL)
return
}
if err := token.CheckResourceIndicator(p, g.expectedResource); err != nil {
challenge.Unauthorized(w, g.prmURL)
return
}
if err := token.CheckIssuerBinding(p, g.expectedIssuer); err != nil {
challenge.Unauthorized(w, g.prmURL)
return
}
ctx = WithPrincipal(ctx, p)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Three checks, three identical failure paths. A request can fail because:
- The token is invalid
- It was issued for a different server
- It came from the wrong authorization server
In each case the client gets back a 401 and a WWW-Authenticate header pointing at discovery.
Nothing about which check failed reaches the other end. An attacker who learns which one tripped gets a free debugging
tool.
What does change on success is what the next handler sees. The context coming back from Validate already carries
the raw token; WithPrincipal layers the validated identity on top of that same context, so anything downstream can
ask who this is without re-deriving it, and anything that later dials out through passthrough.Guard still has the
original token to compare against.
Putting It Together
Wired up, the whole thing is about twenty lines.
func main() {
hs256Validator := validator.NewHs256Validator(issuerURI, []byte(demoHMACSecret))
res := resolver.NewResolver([]validator.TokenValidator{hs256Validator})
tv := token.NewValidator(res)
prmURL := resourceURI + prmPath
guard := middleware.NewGuard(tv, resourceURI, issuerURI, prmURL)
prmHandler := discovery.NewHandler(resourceURI, []string{issuerURI})
mux := http.NewServeMux()
mux.Handle(prmPath, prmHandler)
mux.Handle("/mcp", guard.Wrap(protected))
log.Fatal(http.ListenAndServe(":8080", mux))
}
Discovery is open, /mcp is behind the guard. A client with no token gets pointed at the metadata document:
$ curl -i localhost:8080/mcp
HTTP/1.1 401 Unauthorized
Www-Authenticate: Bearer resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource"
Content-Type: application/json
{"error":"unauthorized"}
With a token that clears all three checks, the handler runs and can read the principal straight off the context.
$ curl -i -H "Authorization: Bearer $TOKEN" localhost:8080/mcp
HTTP/1.1 200 OK
Content-Type: application/json
{"iss":"https://test.local","passthrough_blocked":true,"sub":"test-user"}
That handler also tries to forward the inbound token to a downstream endpoint on the same server, which is exactly the
mistake passthrough.Guard exists to catch. passthrough_blocked is the guard refusing it.
The demo uses HS256 with a shared secret because it's the shortest thing that runs. The reason polytoken exists is multi-issuer RS256 with JWKS rotation, and that's the setup this middleware is actually built for.
What I'd want before running this anywhere real is logging on the middleware's failure paths, since the client learning nothing shouldn't mean I learn nothing either, and the passthrough opt-out for the multi-audience case. Both are small. Neither is written yet.
Code is at polytoken-mcp, built on polytoken.