oauth-resource-server validates bearer tokens that gate access to real HTTP
APIs. A bug here can mean an attacker gets past authentication or scope
checks entirely, so please report vulnerabilities privately rather than
through a public issue.
This crate is pre-1.0. Security fixes are made against the latest published
0.x release only; there is no separate maintenance branch for older minor
versions. Once a fix lands, upgrading to the latest 0.x is the supported
remediation.
| Version | Supported |
|---|---|
latest 0.x |
✅ |
older 0.x |
❌ |
Please use GitHub's private vulnerability reporting instead of opening a public issue or pull request:
- Go to this repository's Security tab.
- Click Report a vulnerability to open a private advisory draft.
- Describe the issue: affected version(s), the security property that
breaks (e.g. "a rejected token is accepted", "the wrong
WWW-Authenticatechallenge is sent", "a key from the wrong provider is trusted"), and, if possible, a minimal reproduction using this crate'stestingfeature fixtures rather than a real deployment's tokens or keys.
Do not include real issuer URLs, client IDs/secrets, or production tokens in
a report — use the crate's test fixtures or a placeholder like
https://idp.example.test/.
You should get an initial response within a few days. If the report is
confirmed, a fix is prepared in the private advisory, a new 0.x version is
published, and the advisory is disclosed once the fix is out, with credit to
the reporter unless anonymity is requested.
Fixed vulnerabilities, newest first. Each also has an entry under
Security in CHANGELOG.md.
- Affected: 0.2.0 through 0.3.0, the
mcpfeature (McpToolScopes) only. Fixed in 0.3.1. - Conditions: serde_json's
raw_valuefeature (or, for theNumbervariant,arbitrary_precision) is enabled in the server's build — axum enablesraw_value, so through Cargo feature unification most axum servers have it — and the MCP server behind the layer parses request bodies intoserde_json::Value(or anything built on it). - Impact: with those features on,
serde_json::Valuereads an object whose first key is$serde_json::private::RawValueas the JSON inside that key's string value. A body such as{"$serde_json::private::RawValue":"{\"method\":\"tools/call\",\"params\":{\"name\":\"<tool>\"}}"}(or one wrapping a whole batch) was classified byMcpToolScopesas an ordinary non-tool-call message needing only the default scopes, while the dispatcher saw atools/callof<tool>: a caller holding the default scopes could run a tool configured to need more. The same shape insidemethodwas misread the same way. Authentication itself, the layers' own required scopes, andRequireScopes/Scopedwere not affected. - Fix: a message with an object key
$serde_json::private::RawValueor$serde_json::private::Numberanywhere in it (including inmethod,paramsor any nested value) is unclassifiable and needs the strictest set (the default and every tool's scopes and claim clauses). This narrows accepted input — such bodies were never sent by a real MCP client — and ships as a patch release under this crate's security-fix policy. - Found by: this repository's
mcp_tool_callsfuzz target. - Remediation: upgrade to 0.3.1 (
cargo update -p oauth-resource-serveron a"0.3"requirement; a"0.2"requirement must move to"0.3").
These are the properties covered by the crate's own test suite and CI, and the ones a report is most likely to concern:
- the JWT algorithm allowlist is checked from the unverified token header
before any network fetch happens, and a header listing critical extensions
(
crit), akidthat is present but not a string ("kid": null), or — withrequire_at_jwt— atypother thanat+jwtis refused there too; - a credential over 16 KiB (
MAX_TOKEN_BYTES) is refused before it is even decoded, whatever it holds; - an unknown
kid(which comes from the unverified header) triggers at most one key refetch per minute, so junk tokens cannot turn the server into an amplifier aimed at the authorization server; a separate hourly refresh is what notices a key the authorization server has withdrawn; - a key refetch runs in a task of its own that holds the refresh lock until the fetch completes, so a caller that stops waiting (a client disconnect, a timeout layer) cannot cancel it halfway and leave the refetch cooldown spent with no keys loaded;
- each JWKS key is narrowed to only the algorithms its own key type can
produce (an HMAC, raw octet,
use: enckey, or key whosekey_opslacksverifyis never usable to verify a signature) — including a key set seeded withOAuthValidatorBuilder::initial_jwks, which goes through the same size cap, key cap and per-key checks as a fetched one. A key the verifier could never use — a P-521 or X25519/X448 key, an RSA key whose modulus is not 2048 to 8192 bits — is skipped, never counted as held. A key that declares noalgstays usable for every allowlisted algorithm its type can produce (a documented RFC 8725 §3.1 deviation), and awarnnaming itskidis logged the first time it appears; - no
OAuthValidatorBuilderoption relaxes a fetch rule: an added root certificate only adds trust anchors (a private key alongside it is refused), an explicit proxy tunnelshttpsfetches end to end, a credential in a plain-httpnon-loopback proxy URL is refused withoutallow_insecure_http, and the proxy URL is never logged, displayed orDebug-printed unredacted (a refused one is not shown at all). Every metadata and JWKS request times out after 10 s by default, andfetch_timeoutaccepts only 1 to 60 s, since the timeout also bounds how long a refresh holds the refresh lock. Only a2xxresponse is read, at most 256 KiB of it and 64 keys; - a credential in the
issuer,jwks_uriorresourceURL (userinfo, or ajwks_uriquery) never reaches a log line, an error or configuration problem message, or theDebugoutput of the config types, the validator,AuthorizedTokenor the layers: every such display masks userinfo, query and fragment, and a value that cannot be parsed is shown only when it has no@,?or#and only visible ASCII. The exception is where the URL is the output:resolveaccepts userinfo inissuerandresource(it refuses only a query or fragment there), and the RFC 9728 metadata document publishes theissuerverbatim inauthorization_serversand every challenge carries the rawresource, so a credential written into either is sent to every client. Put none there; - a refused request's query (which may carry an RFC 6750 §2.3
access_token) never reaches a log line orRejectContext'sDebug: the layers log the path only, and theDebugshows the query as?***; - no proxy — explicit, from
HTTP_PROXY/HTTPS_PROXY/ALL_PROXY, or from the system settings — ever carries a fetch of a loopback URL (it uses a proxy-free client), so the plain-http loopback exemption never crosses the network; non-loopback fetches keep reqwest's own proxy handling, and an explicit proxy replaces the environment and system ones. A fetch that starts on a loopback URL may not be redirected off loopback, so the proxy-free client never carries a hop a proxy should have seen.localhostand*.localhostcount as loopback by name, so that client resolves every name to::1/127.0.0.1itself, never through DNS (which, on some resolvers, forwards*.localhostupstream). The one residual — a redirect from a non-loopback URL to a loopback one may go through an environment or system proxy — is documented in the README's security model; - signature verification and
iss/aud/exp/nbfclaim checks happen inside a singlejsonwebtoken::decodecall, so a claim check can never be reordered to run after a signature has already been treated as valid; issis re-checked as an exact single string after decode, a presentnbfmust be a NumericDate, and a sender-constrained (cnf) token is refused rather than accepted as a bearer token;- the optional claim policy (
allowed_client_ids,max_token_age_secs,required_claims) reads only claims the verified signature covers, runs after those rechecks and before the scope check (so its refusals are 401, never 403), and changes nothing when unset; withmax_token_age_secsset, a token without a readableiatis refused, never waved through, and withallowed_client_idsset aclient_idthat is present but not a non-empty string is refused rather than read past toazp; - every failure mode (fetch error, parse error, unknown key) fails closed — a failed refresh never discards keys already held, and the validator never falls back to accepting a token it cannot verify against the keys it has;
WWW-Authenticateis set (and overwrites whatever a caller-supplied rejection handler returned) on every 401/403 once OAuth is configured, and a layer whose challenge would not be a valid header refuses to build. The status and challenge are decided by one function, shared by the axum layer, thetowerlayer and the publicrefusal()a hand-built integration calls, and every challenge it returns is a valid header value (a validator whose configured challenge would not be one logs aterrorand uses a fallback withoutresource_metadata, and the layers refuse to build with it);- which check refused a token (
InvalidToken::kind()) never changes the response: everyTokenRejection::Invalidis the same 401 with the same challenge, and neither the kind nor the log-only detail reaches a response the crate builds; - a configuration that would accept ID tokens as access tokens, or fetch keys
from (or receive tokens at) a plain-
httpnon-loopback URL — configured, discovered or reached by a redirect — is refused (at startup, or when the fetch is made) unless explicitly opted into. Whether a URL is plainhttp, and whether its host is loopback, is decided on the URL as parsed — the same reading the fetch uses — never on its raw text, so no spelling of a cleartext URL (http:/host,http:host,HTTP:\\host) avoids the opt-in. A spelling the parser has to repair is otherwise accepted and logged as a startupwarn, and the discovery andresource_metadataURLs built from it never leave the host the parser reads in it; AuthLayer(and thetowerfeature'sHttpAuthLayer, which runs the same check) cannot be constructed in a state that silently passes every request through unauthenticated — that requires an explicit, clearly-named constructor. Anoptional()layer still needs a credential mechanism, and passes through only a request that presents no credential at all (aDPoP-scheme or tab-separatedBearervalue counts as presented); anything presented is refused exactly as withoutoptional(), and a credential an outer layer inserted is removed, not passed on;- the
Credential/AuthorizedToken/StaticTokenMatchaxum extractors never turn a missing credential into access: they answer with the layer's own 401 and challenge, and with 500 on a route noAuthLayercovers (theirOptionforms too); - every configured credential header is marked sensitive
(
http::HeaderValue::set_sensitive) before a rejection handler or the inner service sees the request, and anallow_unauthenticatedlayer marksAuthorization, so aDebugof the request downstream never prints a credential; - per-route and per-operation scope requirements (a layer's
require_scopes,RequireScopes, theScopedextractor, themcpfeature'sMcpToolScopes) are an exact all-of match on the token the layer validated, the same matching as the validator's own; a token missing one, and a static token (which has no scopes) unless the application opted in withstatic_token_bypasses_scopes, get 403 through the layer's own refusal path, with a challenge that is always a valid header value (a caller-suppliederror_descriptionis reduced to RFC 6750's character set, never escaped in); a route-level check with no authentication layer in front answers 500, never access;McpToolScopes' claim clauses (default_claim,tool_claim: a verified top-level claim holding at least one listed value, every clause required) are judged in the same place, on the same credential, and refused with the same 403 — its challenge naming scopes only, so no claim name or value ever entersWWW-Authenticate, and its log line naming the claims, never a configured or presented value; a static token meets a clause only withstatic_token_bypasses_scopes; McpToolScopesreads a request body under a limit enforced while it streams (1 MiB by default; a larger body is refused with 413 without being read further), gives a body it cannot classify with certainty (not JSON, no readable tool name, a repeated member two parsers could read differently, or an object key$serde_json::private::RawValueor$serde_json::private::Numberat any depth, whichserde_json::Valuewith serde_json'sraw_value/arbitrary_precisionfeature reads as the JSON inside the key's string) the strictest scope set (and every claim clause in the configuration, never merged by claim name) rather than the default, authorizes every message of a JSON-RPC batch, classifies a body under any method (not onlyPOST, and never trusting a size hint of zero), refuses a request with no credential with the layer's 401 whenever what it needs is not empty (before reading its body when every request needs a scope), parses in one streaming pass that builds no document (memory stays at about the body itself), passes a served body on byte-identical, and never logs body content. Its tool names are matched exactly — byte for byte on the JSON-decoded name — so it relies on the MCP server dispatching tools exactly as well: a dispatcher that normalizes names (case, whitespace) would let a caller reach a scoped tool under a spelling treated as unconfigured, with only the default scopes. That is a documented deployment requirement, not something this crate can check; body reads have no timeout of their own, so the server must set one;- static tokens are compared in constant time (
subtle): with several (StaticTokens), every candidate is compared with every entry, with no early exit once one matches and no branch on which entry matched. A secret's length is not hidden. No static token reaches aDebugimpl or a log line; a set prints its count and labels, and labels are restricted to 1 to 64 visible ASCII characters so they are always log-safe. Each secret the crate holds (a set's entries, the builders' single token, theenvloader's intermediate copies) is wiped from memory when dropped (zeroize); aStaticTokenDecision'sStringand strings the caller owns are not. A whitespace-only static token is no credential: a layer built with only one fails to build rather than silently admitting nobody; - the structured observability output (the
auth.*log fields, theoauth_rs.*spans and, with themetricsfeature, the metric labels) carries only values from fixed sets, a static token's log-safe label, and hosts without scheme, path, credential or query — never a token, secret, claim value, URL query or request body. The span fields taken from a token's unverified header (kid,alg) are cut to 128 characters and every character outside printable ASCII is escaped, so a hostile header cannot inject control characters or terminal escapes into a log.
See CLAUDE.md for the full list and the module each one lives in.
CI checks more than the invariants above, so a regression in them is caught before a release rather than reported after it:
- API compatibility.
cargo semver-checkscompares every pull request (merged ontomaster, before it lands) with the latest release on crates.io, so an accidental breaking change to this crate's own API surface fails CI instead of reaching a consumer on a compatible-version range. It cannot see a major bump of a dependency whose types appear in the public API (serde,serde_json,http,tower-layer,tower-service,http-body,bytes,axum); that stays a manual policy check (seeCLAUDE.md). - Feature combinations and dependency floors.
cargo hackbuilds every feature combination that can compile, and a build against the oldest release each dependency requirement allows keeps the lower bounds inCargo.tomlhonest. - Supply chain.
cargo auditandcargo deny check(deny.toml) fail on a known advisory or a yanked crate, on a dependency licence outside an explicit allowlist, and on any dependency source other than crates.io. Both ignore lists are empty. - Fuzzing.
cargo-fuzztargets infuzz/run after every merge tomasterthat touches the crate's source, against its own parsers: theBearerheader parser, the validator's pre-fetch header checks (check_header,check_crit,check_typ), scope and principal extraction, the RFC 9728 metadata URL and path builders, the discovery-URL builder, the per-entry JWK parse, the per-request 403 challenge, and themcpfeature's JSON-RPC tool-call classification (againstserde_json::Valueas an oracle). They assert invariants as well as looking for panics: for example, a token whose header carriescritis never accepted, and no parsed key ends up with an empty or out-of-allowlist algorithm set. They run after a merge, and on demand, rather than on every pull request or on a schedule.
Fuzzing exercises the parsers, not the full validation path; it complements the test suite rather than replacing it.