Troubleshoot OAuth tokens on MCP servers
Your MCP server is returning 401 or 403 when you expected 200. Before reading any code, decode the token and check four claims. The vast majority of "why isn't this working" turns out to be one of them.
Paste the token at jwt.io and read off:
{
"iss": "...", // who issued the token
"aud": "...", // who the token is for
"exp": 1730000000, // when the token expires (Unix seconds)
"scope": "...", // what the user can do
"sub": "...", // who the user is
"iat": 1729999000 // when the token was issued
}
Then follow the table.
The four-claim checklist
| Symptom | Likely claim | Check this |
|---|---|---|
401 invalid_token with no further detail | iss | Does the token's iss match what your SDK was configured with? |
401 invalid_token with "audience mismatch" | aud | Does aud match your ResourceURI? Note: aud is sometimes a single string, sometimes an array. |
401 invalid_token with "expired" | exp | Is exp in the past relative to your server's clock? Check clock skew. |
403 insufficient_scope | scope | Does scope contain the string your tool requires? Look for typos, casing, namespacing. |
If none of these match the error, the bug is downstream of validation — likely a tool-name mismatch or a policy state issue.
aud mismatch
The most common production bug. Three flavors:
(a) Token has no aud at all. Some OAuth servers omit it. Either configure the auth server to set aud automatically based on which resource the client is hitting, or pass an audience parameter on the /oauth/authorize request.
(b) aud is the OAuth client_id. Hydra default for a long time. Easy mistake — the aud is then per-client, not per-resource-server, so two clients hitting the same server have different aud values. Solution: enable Hydra's audience configuration or, as a stopgap, switch your SDK to check client_id instead of aud (not recommended long-term).
(c) Trailing slash mismatch. Your server's ResourceURI is https://your-mcp.example.com/mcp and the token's aud is https://your-mcp.example.com/mcp/. Pick one and stick to it everywhere — auth server config, SDK config, public URL. The trailing slash matters.
iss mismatch
Less common, but happens when:
- Your auth server moved (e.g. you migrated from
auth-dev.example.comtoauth.example.com) and tokens issued by the old URL are still in circulation. - Your SDK's
Issuerconfig doesn't match what the auth server actually puts iniss. Hydra puts a URL with no trailing slash; some SDKs expect with. Re-checkConfig.Issuer.
Fix: align the two. The auth server's .well-known/openid-configuration shows what iss it actually issues.
exp in the past
The token expired. Three sub-cases:
- The client is sitting on an old token. Should refresh. If it doesn't, the client is buggy or the refresh token also expired.
- Server clock skew. Your MCP server's clock is ahead of the auth server's. Even small skew (60s) can cause this on tokens nearing expiry. Use NTP.
- You set
Nowin Config to a fixed time for testing. Yes, this trips real people up. Remove the override.
The SDK uses time.Now() by default; the configurable Config.Now exists for tests.
insufficient_scope
Token is valid; user's scopes don't include what the tool requires. Check three things:
(a) Spelling and namespacing. Tool requires rs-525da3b4:tools:write; token has rs-525da3b4:tools:read. Or — more pernicious — rs-525da3b4:tools:write vs rs-525da3b4:tools:Write (capitalization). Scope strings are case-sensitive.
(b) The user actually consented to the scope. When a client asks for the OAuth flow, the user can grant a subset of requested scopes. Check what was issued: the JWT's scope claim is the truth.
(c) The tool's mapping is what you think it is. Open the Tools tab in AuthSec and check what scope is currently mapped to this tool. If it's unmapped, you'll get 403 even with the right token — the mapping changed since you set up the test.
Less-common claim issues
sub is wrong. This rarely causes 401/403 by itself, but it can cause downstream confusion if your handler reads sub and acts on it.
active = false from introspection. The token was revoked. Re-authorize.
nbf (not-before) in the future. Same shape as exp but at the start of validity. Rare in MCP. Same clock-skew fix applies.
Specific error shapes from the Go SDK
When the SDK denies a request, the response body has structured detail beyond the HTTP status:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="audience mismatch", resource_metadata="..."
Content-Type: application/json
{"error":"invalid_token","error_description":"audience mismatch"}
The error field maps to OAuth standard codes (invalid_request, invalid_token, insufficient_scope). The error_description is the SDK's best-effort human reason. The resource_metadata URL takes you to RFC 9728 metadata explaining how to re-authenticate.
For 403:
{
"error": "insufficient_scope",
"required_scopes": ["rs-xyz:tools:write"]
}
The required_scopes array tells the client exactly what's missing, so a well-built MCP client can request a step-up authorization.
Last resort — log the failure path
If none of the four claims explain it, enable verbose SDK logging:
cfg := authsec.Config{
// ...
Logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})),
}
Every validation attempt and authorization decision is logged with the principal, the requested tool, and the reason. The log line will tell you exactly which check failed.
Related
- JWT vs token introspection
- Preventing confused-deputy attacks — why
audis the most important claim - Go SDK reference — error types and logging
- From zero to a launched MCP server — the working baseline to diff against