Skip to main content

Crate oauth_resource_server

Crate oauth_resource_server 

Source
Expand description

§oauth-resource-server

crates.io docs.rs CI license: MIT MSRV 1.89

Protect a Rust HTTP API with OAuth 2.0 access tokens. This crate verifies JWT access tokens (RFC 9068) against your authorization server’s published signing keys, answers refusals with RFC 6750 WWW-Authenticate challenges, serves RFC 9728 protected-resource metadata so clients can discover where to get a token, and can accept a static API key alongside OAuth. It ships an axum/tower middleware, and the validator underneath works with any framework running on a Tokio runtime.

§What it is, and what it is not

It is the resource server half of OAuth: the part in front of your API that decides whether the bearer token on a request is good enough.

  • It validates JWT access tokens locally against a JWKS: signature, iss, aud, exp, nbf, typ, and the scopes you require. There is no per-request call to the authorization server.
  • It works with any standards-following authorization server. Every provider difference (audience value, scope claim name and shape, signing algorithm, typ, username claim) is a configuration field, never a code path. The provider guide has recipes, each labeled with exactly how far it has been tested.
  • It tells clients where to authenticate: every 401 and 403 carries a challenge pointing at the metadata document, which names your authorization server.
  • It accepts a static API key alongside OAuth, or instead of it, so an existing deployment can move to OAuth without an outage — and several keys at once, so a key can be rotated without one either.
  • It fails closed, down to the middleware refusing to build with no credential configured.

It is not:

  • An OAuth client or login flow. It has no redirects, no PKCE and no token exchange. Callers get their tokens from the authorization server.
  • An authorization server. It never issues, refreshes or revokes a token.
  • An opaque-token validator. It accepts JWT access tokens only; there is no RFC 7662 introspection yet. Several servers issue opaque tokens by default and need a setting changed to issue JWTs.
  • Sender-constrained. It accepts plain bearer tokens only, with no DPoP and no mTLS binding. A token the authorization server did bind (one with a cnf claim) is refused rather than accepted as a bearer token.
  • Hot-reloadable. Configuration takes effect when the validator and layer are built, which in practice means at process start.

Security model draws the boundary precisely.

§Install

cargo add oauth-resource-server --features axum,serde

The axum quickstart below also uses axum, tokio and a serde format crate (YAML here; any serde format works):

cargo add axum@0.8 serde_yaml_ng
cargo add tokio --features full

or in Cargo.toml:

[dependencies]
oauth-resource-server = { version = "0.2", features = ["axum", "serde"] }

§Features

FeatureDefaultWhat it enables
rustls-tlsyesThe rustls TLS backend for fetching the JWKS and discovery documents, trusting only the Mozilla root certificates compiled into the binary.
rustls-tls-native-rootsnorustls, trusting the operating system’s certificate store (for an authorization server behind a private CA) — in addition to the Mozilla roots while the default rustls-tls is on too (reqwest merges both into one root store), and instead of them only with default-features = false.
native-tlsnoThe platform TLS backend (OpenSSL on Linux), trusting the operating system’s certificate store. Enabled together with a rustls feature, it is the one used (reqwest’s choice), so the OS store applies rather than the Mozilla roots.
serdenoDeserialize/Serialize for OAuthConfig, to load it from YAML, TOML, JSON or any other serde format, and Serialize for InvalidToken (as its kind label, never the log-only detail).
envnoThe env module: load the config and secrets from environment variables, with VAR_FILE support.
towernoThe http_layer module: HttpAuthLayer, an authentication layer for any tower service over http::Request<B> (hyper, tonic, …), whatever its body types. See Using with other frameworks.
axumnoThe axum module: the AuthLayer middleware, metadata_router, axum extractors for Credential and AuthorizedToken, and per-route scopes (RequireScopes, the Scoped<S> extractor). Implies tower.
mcpnoThe mcp module, an integration helper for Model Context Protocol servers: McpToolScopes, a tower layer that requires scopes per tool by reading the JSON-RPC tools/call in the request body. No MCP SDK: it parses with serde_json and reads the body with http-body and bytes, all already in every build. Implies tower. See Per-tool scopes.
metricsnoCounters and a gauge for authentication decisions and JWKS refreshes, through the metrics facade, and the observability module naming them. See Observability. Off, it adds no dependency and records nothing.
testingnoA fake authorization server (TestAuthority), a fluent token builder, and the throwaway signing keys behind them, for your tests only. Never enable it in a production build. It follows semver like the rest of the crate.

The core (config, validator, authenticate, refusal, static_token_policy) is always available and depends on no web framework.

§Choosing a TLS backend

At least one of rustls-tls, rustls-tls-native-roots and native-tls must be enabled. Signing keys are fetched over HTTPS, and with no backend the crate fails to compile rather than failing every fetch at runtime.

Which certificates are trusted depends on the feature. rustls-tls, the default, trusts only the Mozilla root set built into the binary (webpki-roots); it ignores the operating system’s trust store and SSL_CERT_FILE. That suits an authorization server with a public certificate, and a minimal container with no CA bundle. A self-hosted server whose certificate comes from a private CA fails every fetch with a TLS error under it. Either add the CA in code with OAuthValidator::builder(..).add_root_certificate_pem(..), which works with every backend, or use rustls-tls-native-roots (rustls plus the OS store) or native-tls (the platform library plus the OS store) and install the CA into the OS store as usual. rustls-tls-native-roots adds the OS store to the Mozilla roots while the default rustls-tls feature stays on — reqwest loads both into one root store — so to trust the OS store instead, turn the defaults off:

[dependencies]
oauth-resource-server = { version = "0.2", default-features = false, features = ["rustls-tls-native-roots", "axum", "serde"] }

(and the same default-features = false on any [dev-dependencies] entry, for the reason given below for native-tls). With native-tls and a rustls feature both enabled, reqwest uses native-tls, so the OS store is what applies (and what add_root_certificate_pem adds to), not the Mozilla roots.

add_root_certificate_pem adds trust anchors; it never replaces the backend’s own roots. A PEM file may hold one certificate or a bundle, and a file with no certificate in it, one the TLS backend cannot use, or any private-key block fails build() rather than being ignored. Pass only real CA certificates: under rustls a trust anchor keeps only its subject, public key and name constraints, ignoring basicConstraints and keyUsage, so a leaf (CA:FALSE) certificate passed here becomes an anchor that can issue for any host.

use std::sync::Arc;

use oauth_resource_server::{KeyNaming, OAuthConfig, OAuthValidator};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let resolved = OAuthConfig {
        enabled: true,
        issuer: "https://idp.internal.example.com/".into(),
        audience: "example-api".into(),
        resource: "https://api.example.com/".into(),
        required_scope: Some("api:read".into()),
        ..OAuthConfig::default()
    }
    .resolve(KeyNaming::Dotted("oauth"))?
    .expect("OAuth is enabled");

    let validator = Arc::new(
        OAuthValidator::builder(&resolved)
            .add_root_certificate_pem(&std::fs::read("/etc/ssl/private-ca.pem")?)
            .build()?,
    );
    assert!(!validator.is_ready()); // no I/O yet: keys load on first use
    Ok(())
}

A root added this way can vouch for any host name on this validator’s fetches, so add the CA that issues the authorization server’s certificate and nothing broader. The other fetch options (proxy, timeout, a key set to start from) are in Fetch options.

If your application already uses reqwest with native-tls, switch this crate over as well, so the binary carries one TLS stack instead of two:

[dependencies]
oauth-resource-server = { version = "0.2", default-features = false, features = ["native-tls", "axum", "serde"] }

[dev-dependencies]
oauth-resource-server = { version = "0.2", default-features = false, features = ["native-tls", "testing"] }

Repeat default-features = false in [dev-dependencies]. Cargo merges a dependency’s features across every table it appears in, so a dev-dependency entry that leaves the defaults on brings rustls-tls back into every test build.

§Quickstart: axum

A complete server: configuration from YAML, one shared validator, the auth layer on the protected routes, and the discovery document served outside it.

use std::sync::Arc;

use axum::{Router, routing::get};
use oauth_resource_server::axum::{AuthLayer, metadata_router};
use oauth_resource_server::{KeyNaming, OAuthConfig, OAuthValidator, static_token_policy};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // 1. Configuration, from any serde format (feature `serde`).
    let config: OAuthConfig = serde_yaml_ng::from_str(
        r#"
        enabled: true
        issuer: "https://auth.example.com/"   # byte-exact, trailing slash and all
        audience: "my-api"                    # what your server puts in `aud`
        resource: "https://api.example.com"   # this API's public URL
        required_scope: "api:read"
        scopes_supported: ["api:read"]
        "#,
    )?;
    // `KeyNaming` decides how problems name settings: `oauth.issuer`, ...
    let resolved = config
        .resolve(KeyNaming::Dotted("oauth"))?
        .expect("enabled: true, so resolve returns Some");

    // 2. One validator per process. The background task loads the signing keys
    //    now and re-reads them every hour.
    let oauth = Arc::new(OAuthValidator::new(&resolved)?);
    oauth.spawn_background_refresh();

    // 3. The auth layer. No static API key here; the dual-mode quickstart
    //    below adds one.
    let decision = static_token_policy(None, Some(&resolved), false)?;
    let auth = AuthLayer::from_decision(decision, Some(Arc::clone(&oauth)))?;

    // 4. Protect the API, and serve the metadata document OUTSIDE the layer:
    //    a caller with no token uses it to find out where to get one.
    let app = Router::new()
        .route("/v1/things", get(|| async { "the protected things" }))
        .route_layer(auth)
        .merge(metadata_router(Some(oauth)));

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
    axum::serve(listener, app).await?;
    Ok(())
}

What a client sees (challenge headers wrapped for reading; on the wire each is one line):

GET /v1/things                           (no token)
401  WWW-Authenticate: Bearer error="invalid_token",
       resource_metadata="https://api.example.com/.well-known/oauth-protected-resource",
       scope="api:read"

GET /.well-known/oauth-protected-resource
200  {"authorization_servers":["https://auth.example.com/"],
      "bearer_methods_supported":["header"],
      "resource":"https://api.example.com",
      "scopes_supported":["api:read"]}

GET /v1/things   Authorization: Bearer <valid token without api:read>
403  WWW-Authenticate: Bearer error="insufficient_scope", scope="api:read",
       resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"

GET /v1/things   Authorization: Bearer <valid token with api:read>
200  the protected things

AuthLayer is a tower::Layer, so .route_layer(auth) or .layer(auth) protects routes directly. It is also the state for the require_auth middleware function: .route_layer(axum::middleware::from_fn_with_state(auth, require_auth)) behaves identically, for composing with other from_fn middleware.

The runnable version, examples/axum_basic.rs, starts a fake authorization server on a loopback port, so it needs no network.

§Reading the caller in a handler

On success the layer inserts a Credential into the request extensions and, for an OAuth token, the AuthorizedToken as well (for a static key, a StaticTokenMatch naming which key; see Rotating a static API key). All three are axum extractors, so a handler takes them as arguments:

use oauth_resource_server::Credential;

async fn whoami(credential: Credential) -> String {
    match credential {
        Credential::OAuth(token) => format!(
            "subject {:?}, known as {:?}, with scopes {:?}",
            token.subject, token.principal, token.scopes
        ),
        Credential::StaticToken => "the static API key".to_string(),
        // `Credential` is `#[non_exhaustive]`.
        _ => "some other credential".to_string(),
    }
}

When the layer inserted nothing, the extractors refuse the request themselves, and never pass it to the handler:

The request…Credential / AuthorizedTokenOption<Credential> / Option<AuthorizedToken>
was accepted by the layerthe valueSome(value)
passed an optional() layer with no credential, or an allow_unauthenticated() layerthe layer’s own 401 and WWW-Authenticate challengeNone
was accepted with the static token (AuthorizedToken only, unless an outer layer inserted one; see below)the layer’s own 401 and challengeNone
was accepted with an OAuth token (StaticTokenMatch only)the layer’s own 401 and challengeNone
is on a route no AuthLayer covers500, logged at error500, logged at error

“The layer’s own 401” comes from the same code as the layer’s refusals, so it has the same status, challenge and on_reject body. A route outside every layer is a mistake in the server’s wiring, not something a caller can fix by authenticating. It gets a 500 rather than a 401, because a 401 would send an OAuth client into an authorization flow that can never succeed there. Even Option<..> refuses in that case, so a wiring mistake can never read as an anonymous caller. Extension<Credential> and Extension<AuthorizedToken> still work as before.

Nested layers: an optional() layer first removes any Credential and AuthorizedToken an outer layer inserted, so its handlers only ever see what it accepted itself. Strict layers never remove anything, so the extensions accumulate. Credential is the innermost layer’s decision, but an AuthorizedToken may come from an outer layer. For example, an inner static-key layer under an outer OAuth layer leaves the outer layer’s token in place. Read Credential when the innermost decision is what matters.

When a static token is also configured, extract Credential rather than AuthorizedToken: a static-token request has no AuthorizedToken, so an AuthorizedToken extractor refuses it with a 401. Key per-user decisions on subject, the verbatim signed sub; principal comes from a configurable claim list and is meant for logs.

§Per-route and per-handler scopes

The validator’s required scopes are the floor every token must meet. For more on some routes — a write scope on the routes that write — require it where it applies, on top of that floor. Every way below checks the token the layer already validated (one validator, one key cache; no second validator), and refuses a token without the scopes with 403 and a challenge naming the scopes this request needs: the validator’s required scopes followed by the route’s, Bearer error="insufficient_scope", scope="api:read api:write", resource_metadata="…". A client re-authorizes for exactly that set and passes both checks (this is the per-request challenge MCP’s scope step-up expects).

ForUse
everything behind a layerAuthLayer::builder().require_scopes([..]) (or HttpAuthLayerBuilder::require_scopes)
some routesthe RequireScopes::new([..]) route layer, behind the auth layer
one handlerthe Scoped<S> extractor, with S a ScopeSet type of yours
one MCP toolMcpToolScopes (feature mcp)
any other codeAuthorizedToken::require_scopes(&[..]), and refusal_for_scopes() for the 403
use axum::{Router, routing::{get, post}};
use oauth_resource_server::axum::{AuthLayer, RequireScopes, ScopeSet, Scoped};

struct DocsAdmin;
impl ScopeSet for DocsAdmin {
    const SCOPES: &'static [&'static str] = &["docs:admin"];
}

async fn purge(token: Scoped<DocsAdmin>) -> String {
    format!("purged by {:?}", token.subject)
}

fn app(auth: AuthLayer) -> Router {
    Router::new()
        .route("/docs/new", post(|| async { "written" }))
        // Applies to the routes added above it.
        .route_layer(RequireScopes::new(["docs:write"]))
        .route("/docs/purge", post(purge))
        .route("/docs", get(|| async { "listed" }))
        // Added last, so it runs first: everything above is behind it.
        .route_layer(auth)
}

They refuse through the layer’s own refusal path: its status, challenge and on_reject body. A request with no credential (an optional() layer passed it through) gets the layer’s own 401; a route no layer covers gets 500, logged at error, never access. Scopes are matched exactly, all-of, the same way the validator matches its own.

A static token has no scopes, so wherever scopes are required it is refused with the same 403 — fail-safe, even though in dual mode it was never held to the validator’s scopes: a route that names a scope asks it of every credential. Where the static key is meant to be a full-access key, say so with static_token_bypasses_scopes() (on the layer builders, RequireScopes and McpToolScopes). A layer with required scopes, no OAuth validator and no bypass refuses to build (AuthLayerError::ScopesNeedOAuth): nothing could ever pass it; so does build_with_decision with an Unauthenticated decision and required scopes (AuthLayerError::ScopesWithoutAuthentication), rather than drop them. Behind a static-only layer a route check’s 403 carries the bare Bearer error="insufficient_scope" challenge. Scoped<S> extracts an OAuth token, so it always refuses a static token. A request presenting both a static token and an OAuth token (in two sources) is judged by the static token, which the layer checks first.

Scopes written in code go to RequireScopes::new (and McpToolScopes’ default/tool), which panic on a value that is not a valid scope-token; scopes read from configuration go to RequireScopes::try_new (and try_default/try_tool/try_body_limit), which return an error naming the bad value. A ScopeSet with an invalid scope does not compile.

A check of your own in the handler works too; decide it fail-closed: allow only a credential you positively recognize.

use axum::http::StatusCode;
use oauth_resource_server::Credential;

async fn delete_thing(credential: Option<Credential>) -> StatusCode {
    let allowed = match &credential {
        Some(Credential::OAuth(token)) => token.has_scope("api:write"),
        // The static API key has no scopes. Whether it may write is your
        // decision; say so explicitly rather than falling through.
        Some(Credential::StaticToken) => false,
        // No credential at all (an `optional()` or `allow_unauthenticated()`
        // layer passed the request through), or a kind this code does not
        // know: deny.
        _ => false,
    };
    if !allowed {
        return StatusCode::FORBIDDEN;
    }
    // ... do the write ...
    StatusCode::NO_CONTENT
}

Avoid Option<AuthorizedToken> with an if let Some(token) = .. { if !token.has_scope(..) { deny } } check: None covers a static-token request and every request that an optional() or allow_unauthenticated() layer passed through, so that shape lets all of them through the write check. Scopes are matched exactly, with no hierarchy: if your authorization server means api:write to imply api:read, check for either here.

§Optional authentication

For routes that serve everyone but personalize for an authenticated caller, or an API that is open for reads but authenticated for writes, AuthLayerBuilder::optional() lets a request that presents no credential through with nothing inserted. A credential that is presented but not accepted is still refused, exactly as without optional():

use std::sync::Arc;

use axum::{Router, routing::get};
use oauth_resource_server::axum::{AuthLayer, AuthLayerError};
use oauth_resource_server::{AuthorizedToken, OAuthValidator};

async fn front_page(token: Option<AuthorizedToken>) -> String {
    match token {
        Some(token) => format!("welcome back, {:?}", token.principal),
        None => "welcome, visitor".to_string(),
    }
}

fn app(oauth: Arc<OAuthValidator>) -> Result<Router, AuthLayerError> {
    let optional = AuthLayer::builder().oauth(oauth).optional().build()?;
    Ok(Router::new()
        .route("/", get(front_page))
        .route_layer(optional))
}
The request carries…An optional() layer
no credential: every configured header absent or blankpasses it through; the handler sees None
a valid credential with the required scopesinserts it, as without optional()
an invalid, expired or unknown credential, or the wrong static tokenthe same 401 and challenge as without optional()
a valid token without the required scopesthe same 403 and challenge as without optional()

“Blank” is what authenticate() reports as Missing: an empty or whitespace-only value, Bearer followed by nothing, or an Authorization value that uses some other scheme (Basic ...), which carries no bearer credential. Some values count as a presented credential even so, and get exactly the refusal a non-optional layer sends:

  • a header value that is not visible ASCII;
  • a non-blank later value of a repeated header;
  • any DPoP ... value, because a sender-constrained token must be refused, not served as anonymous;
  • Bearer followed by a tab and a token.

optional() still requires a static token or a validator to build. See the security model below.

§Reading the verified claims

Beyond subject, principal and scopes, an AuthorizedToken carries what the signature covered, so a handler never decodes the JWT a second time: issuer, audiences (a single-string aud normalized to a list), expires_at, issued_at, client_id (RFC 9068 client_id, else azp) and jti. Everything else, such as groups, roles, email or a tenant id, is in the claim set: claims() returns it raw, and claims_as::<T>() deserializes it into your own type.

use std::time::SystemTime;

use oauth_resource_server::AuthorizedToken;
use serde::Deserialize;

#[derive(Deserialize)]
struct MyClaims {
    #[serde(default)]
    groups: Vec<String>,
}

async fn admin_only(token: AuthorizedToken) -> String {
    let groups = token.claims_as::<MyClaims>().map(|c| c.groups).unwrap_or_default();
    if !groups.iter().any(|g| g == "admins") {
        return "not an admin".to_string();
    }
    // Close a long-lived stream when the token behind it expires.
    let left = token.expires_at.duration_since(SystemTime::now()).unwrap_or_default();
    format!(
        "client {:?}, token valid for another {}s",
        token.client_id,
        left.as_secs()
    )
}

The AuthorizedToken extractor behaves as the table in Reading the caller in a handler describes: a request the layer inserted no token into, such as a static-key request or an optional() pass-through, is refused before the handler runs. Take Option<AuthorizedToken> to serve those requests too.

The claims are exactly what the signature covered, and they are bounded by the 16 KiB credential cap. They can hold personal data, so Debug on an AuthorizedToken prints claim names only, never values. In a test, build a token with AuthorizedToken::new(..) and the with_claims / with_client_id / with_expires_at (and so on) builders; new leaves expires_at at the year 2100 so a fixture is never already expired.

§More quickstarts

§Configuration from environment variables

With the env feature, every setting is read from <PREFIX><FIELD> or, for a file mounted by Docker or Kubernetes secrets, from the path in <PREFIX><FIELD>_FILE.

use std::sync::Arc;

use oauth_resource_server::OAuthValidator;
use oauth_resource_server::env::{oauth_config_from_env, secret_from_env};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Reads MYAPP_OAUTH_ISSUER (or MYAPP_OAUTH_ISSUER_FILE), MYAPP_OAUTH_AUDIENCE,
    // MYAPP_OAUTH_RESOURCE, MYAPP_OAUTH_REQUIRED_SCOPE, ... and returns Ok(None)
    // when none of the identifying variables is set.
    if let Some(resolved) = oauth_config_from_env("MYAPP_OAUTH_")? {
        let oauth = Arc::new(OAuthValidator::new(&resolved)?);
        oauth.spawn_background_refresh();
    }

    // Any other secret the same way: MYAPP_API_KEY, or MYAPP_API_KEY_FILE.
    // Setting both is an error, not a silent preference.
    let _api_key: Option<String> = secret_from_env("MYAPP_API_KEY")?;
    Ok(())
}
MYAPP_OAUTH_ISSUER=https://auth.example.com/
MYAPP_OAUTH_AUDIENCE=my-api
MYAPP_OAUTH_RESOURCE=https://api.example.com
MYAPP_OAUTH_REQUIRED_SCOPE=api:read
MYAPP_API_KEY_FILE=/run/secrets/api_key

OAuth is on when any of ISSUER, JWKS_URI, AUDIENCE, AUDIENCES or RESOURCE is set, so a partial set fails at startup naming what is missing instead of silently starting unauthenticated. <PREFIX>ENABLED=false turns it off with the other variables left in place. Environment variables describes how values are parsed, and examples/env_config.rs applies an application default (a required scope) before the config is resolved.

§A static API key alongside OAuth, and moving off it

A static token (an API key you manage yourself) and OAuth can run side by side. That lets a deployment already running on a static token turn OAuth on without an outage:

  1. Before: static token only. static_token_policy(Some(key), None, false) returns StaticOnly.
  2. Turn OAuth on and keep the key. static_token_policy returns StaticAndOAuth: both credentials work, existing clients carry on, and new clients use OAuth.
  3. Once every client has moved, set accept_static_bearer: false, which makes it return StaticIgnored (the key stops working although it is still configured), or remove the key, which makes it return OAuthOnly.

Each step is a config change and a restart, and no step locks anyone out.

use oauth_resource_server::{
    NoAuthConfigured, ResolvedOAuthConfig, StaticTokenDecision, static_token_policy,
};

fn decide(
    api_key: Option<String>,
    oauth: Option<&ResolvedOAuthConfig>,
) -> Result<StaticTokenDecision, NoAuthConfigured> {
    let decision = static_token_policy(api_key, oauth, /* allow_unauthenticated */ false)?;
    match &decision {
        StaticTokenDecision::StaticAndOAuth(_) => println!("API key and OAuth both accepted"),
        StaticTokenDecision::StaticOnly(_) => println!("API key only; consider enabling OAuth"),
        StaticTokenDecision::OAuthOnly => println!("OAuth only"),
        StaticTokenDecision::StaticIgnored => {
            println!("OAuth only; the API key is ignored (accept_static_bearer: false)")
        }
        StaticTokenDecision::Unauthenticated => println!("WARNING: no authentication"),
        // `#[non_exhaustive]`: refuse to start on a decision this code does not know.
        _ => panic!("unrecognized decision {decision:?}"),
    }
    Ok(decision)
}

// Nothing configured and no explicit opt-out: refuse to start.
assert!(decide(None, None).is_err());
assert_eq!(
    decide(Some("k3y".into()), None).unwrap(),
    StaticTokenDecision::StaticOnly("k3y".into())
);

Then build the layer from the decision, which applies accept_static_bearer for you:

use std::sync::Arc;

use oauth_resource_server::axum::{AuthLayer, AuthLayerError};
use oauth_resource_server::{OAuthValidator, StaticTokenDecision};

fn layer(
    decision: StaticTokenDecision,
    oauth: Option<Arc<OAuthValidator>>,
) -> Result<AuthLayer, AuthLayerError> {
    AuthLayer::from_decision(decision, oauth)
}

Always go through static_token_policy. It is the only thing that reads accept_static_bearer, so handing the key straight to AuthLayer::builder().static_token(..) would make that setting do nothing. It contains no logging and no message text, so your application can explain the decision at startup in its own words.

§Rotating a static API key with zero downtime

A layer can hold several static keys at once, as a StaticTokens set, and tells the handler which one a request used. Rotating a key then needs no window in which clients are locked out:

  1. Add the new key next to the old one. With the env feature, static_tokens_from_env("MYAPP_API_KEY") reads MYAPP_API_KEY (the current key, labeled "current") and MYAPP_API_KEY_NEXT (the new one, labeled "next"), each also as a _FILE. Restart: both keys work.
  2. Move every client to the new key. The StaticTokenMatch label shows which clients still send the old one.
  3. Promote the new key. Set MYAPP_API_KEY to it and unset MYAPP_API_KEY_NEXT (a restart with both holding the same value is fine: it is one key). Restart: only the new key works.

<VAR>_NEXT without <VAR> is an error (a half-done rotation), as is setting both a variable and its _FILE. There is deliberately no whitespace-separated list form: it could not carry labels.

accept_static_bearer still applies, through static_token_policy, which decides on the current key. The layer’s static_tokens set follows that decision: kept when it carries a static token, dropped with it on StaticIgnored, and refused (AuthLayerError::DecisionWithoutStaticToken) alongside a decision the policy made without any static token.

use std::sync::Arc;

use oauth_resource_server::axum::AuthLayer;
use oauth_resource_server::env::{secret_from_env, static_tokens_from_env};
use oauth_resource_server::{
    OAuthValidator, ResolvedOAuthConfig, StaticTokenMatch, static_token_policy,
};

fn auth_layer(
    oauth_config: Option<&ResolvedOAuthConfig>,
    oauth: Option<Arc<OAuthValidator>>,
) -> Result<AuthLayer, Box<dyn std::error::Error>> {
    // The current key alone is what the policy decides on...
    let current = secret_from_env("MYAPP_API_KEY")?;
    // ...and the layer accepts it plus MYAPP_API_KEY_NEXT during a rotation.
    let keys = static_tokens_from_env("MYAPP_API_KEY")?;
    let decision = static_token_policy(current, oauth_config, false)?;
    Ok(AuthLayer::builder()
        .optional_oauth(oauth)
        .optional_static_tokens(keys)
        .build_with_decision(decision)?)
}

// `None` for an OAuth request; `Some` with the key's label for a static one.
async fn audit(matched: Option<StaticTokenMatch>) -> String {
    match matched.as_ref().map(StaticTokenMatch::label) {
        Some(Some(label)) => format!("static key {label}"),
        Some(None) => "static key".to_string(),
        None => "not a static key".to_string(),
    }
}

For one key per client, build the set yourself and label each entry: StaticTokens::new().with(Some("ci"), ci_key)?.with(Some("backup-job"), backup_key)?. with refuses a blank secret, a secret already in the set, a repeated label, and a label that is not 1 to 64 visible ASCII characters, so every label is safe to log. AuthLayerBuilder::static_tokens (and HttpAuthLayerBuilder’s) takes the set; static_token(..) alongside it adds that key too. Outside the layers, authenticate_with_static_tokens returns the StaticTokenMatch next to the Credential.

§Several credential sources

Accept a credential from more than one header, such as Authorization: Bearer for OAuth clients and a raw X-Api-Key header for scripts:

use std::sync::Arc;

use axum::http::HeaderName;
use oauth_resource_server::axum::{AuthLayer, AuthLayerError, CredentialSource};
use oauth_resource_server::{OAuthValidator, StaticTokenDecision};

fn layer(
    decision: StaticTokenDecision,
    oauth: Arc<OAuthValidator>,
) -> Result<AuthLayer, AuthLayerError> {
    AuthLayer::builder()
        .oauth(oauth)
        .sources([
            CredentialSource::authorization_bearer(),
            CredentialSource::Raw(HeaderName::from_static("x-api-key")),
        ])
        .build_with_decision(decision)
}

Every source is read, and every candidate is checked against every mechanism (static token and OAuth), so a bad credential in one header never hides a good one in another. All candidates are first checked against the signing keys already held. Only if none is accepted may an unknown kid trigger a key refetch, so a foreign JWT in one header (a proxy’s own token, say) does not make the request wait on the authorization server.

§Custom rejection bodies

By default a refusal has an empty body. on_reject supplies the body and any extra headers, for an API whose errors are JSON, say:

use std::sync::Arc;

use axum::Json;
use axum::response::IntoResponse;
use oauth_resource_server::axum::{AuthLayer, AuthLayerError, RejectContext};
use oauth_resource_server::{OAuthValidator, TokenRejection};

fn layer(oauth: Arc<OAuthValidator>) -> Result<AuthLayer, AuthLayerError> {
    AuthLayer::builder()
        .oauth(oauth)
        .on_reject(|cx: RejectContext<'_>| {
            let error = match cx.rejection {
                TokenRejection::InsufficientScope => "insufficient_scope",
                _ => "unauthorized",
            };
            (cx.status, Json(serde_json::json!({ "error": error }))).into_response()
        })
        .build()
}

The callback shapes the response but cannot change the outcome. Whatever it returns, the crate sets the status (401 for a missing or invalid credential, 403 for insufficient scope) and sets WWW-Authenticate, replacing any value the callback set: to the validator’s challenge when OAuth is configured, and otherwise to the static challenge described below. RejectContext also carries the request’s method, URI and headers, so the body can follow Accept. Never put the detail inside TokenRejection::Invalid in the body: it says which check failed, which is an oracle for an attacker. The layer logs it instead. (Its kind() is coarser, but this crate’s own responses never carry it either; whether to expose it is your decision.) RejectContext’s Debug prints header names but no header values, and the URI’s path with any query shown as ?*** (a client may put an access_token there), and the credential headers are marked sensitive, so tracing::warn!(?cx) in the callback does not log the token.

§Metrics: counting refusals by kind

TokenRejection::Invalid holds an InvalidToken, whose kind() is an InvalidTokenKind: which check refused the token (Expired, BadSignature, WrongAudience, KeySetUnavailable, …). kind().as_str() is a stable, low-cardinality snake_case label, so it can go straight into a metrics label; the human-readable detail() cannot (it is unbounded, and for logs only). on_reject sees every refusal, so it is one place to count them:

use std::sync::Arc;

use axum::response::IntoResponse;
use oauth_resource_server::axum::{AuthLayer, AuthLayerError, RejectContext};
use oauth_resource_server::{InvalidTokenKind, OAuthValidator, TokenRejection};

/// Stand-in for your metrics library's counter.
fn count_refusal(reason: &'static str) {
    let _ = reason;
}

fn layer(oauth: Arc<OAuthValidator>) -> Result<AuthLayer, AuthLayerError> {
    AuthLayer::builder()
        .oauth(oauth)
        .on_reject(|cx: RejectContext<'_>| {
            let reason = match cx.rejection {
                TokenRejection::Missing => "missing",
                TokenRejection::InsufficientScope => "insufficient_scope",
                TokenRejection::Invalid(invalid) => {
                    if invalid.kind() == InvalidTokenKind::KeySetUnavailable {
                        // The authorization server is unreachable: an outage to
                        // alert on, not junk traffic.
                        tracing::error!(detail = %invalid, "signing keys unavailable");
                    }
                    invalid.kind().as_str()
                }
                _ => "other",
            };
            count_refusal(reason);
            cx.status.into_response()
        })
        .build()
}

The same TokenRejection comes back from authenticate and OAuthValidator::validate on any other stack. Every kind is a 401 invalid_token: the kind never changes the response. The labels, and which kind a given refusal carries, are stable across releases (a new kind may be added in a minor release, so match with a wildcard arm); the detail text is not.

With the metrics feature the crate counts every decision itself, with the same labels (oauth_rs_requests_total{stage, outcome, mechanism, reason}), so a callback like this one is needed only for a metrics library the facade does not reach; see Observability.

Without OAuth, a 401 carries WWW-Authenticate: Bearer error="invalid_token" (axum::DEFAULT_STATIC_CHALLENGE), because RFC 9110 §15.5.2 requires every 401 to carry a challenge. AuthLayerBuilder::static_challenge sets another value, such as Bearer realm="my-api", or None to send no challenge at all and keep whatever your callback set. None departs from RFC 9110; use it only to keep an existing API’s responses unchanged. examples/multiple_sources.rs combines this with several sources and runs without network.

§CORS for browser-based clients

A browser-based client — a web app calling your API directly with fetch, or an MCP client running in a browser — needs two things this crate does not add for you:

  1. CORS on every route the browser calls, including the metadata route, or the browser’s preflight OPTIONS request fails before your handler ever runs.
  2. Access-Control-Expose-Headers: WWW-Authenticate, or the browser receives the header on the wire but hides it from JavaScript: Response.headers.get('WWW-Authenticate') returns null, and the client cannot read resource_metadata off a 401 to start its authorization flow.

Add tower-http’s CorsLayer as the outermost layer, so it also covers the metadata route and preflight requests to routes behind AuthLayer. A browser-based MCP client additionally needs the Streamable HTTP transport’s own headers and methods allowed, not just Authorization:

use axum::Router;
use axum::body::Body;
use axum::http::header::{
    AUTHORIZATION, CONTENT_TYPE, HeaderName, InvalidHeaderValue, WWW_AUTHENTICATE,
};
use axum::http::{HeaderValue, Method, Request};
use tower::ServiceExt;
use tower_http::cors::CorsLayer;

// Streamable HTTP headers a browser-based MCP client's requests and
// responses need CORS to cover, beyond `Authorization`/`Content-Type`:
static MCP_SESSION_ID: HeaderName = HeaderName::from_static("mcp-session-id");
static MCP_PROTOCOL_VERSION: HeaderName = HeaderName::from_static("mcp-protocol-version");
static LAST_EVENT_ID: HeaderName = HeaderName::from_static("last-event-id");

fn cors_for(client_origin: &str) -> Result<CorsLayer, InvalidHeaderValue> {
    Ok(CorsLayer::new()
        .allow_origin(HeaderValue::from_str(client_origin)?)
        // `DELETE` is how an MCP client ends a session.
        .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])
        .allow_headers([
            AUTHORIZATION,
            CONTENT_TYPE,
            // The MCP protocol version the client negotiated.
            MCP_PROTOCOL_VERSION.clone(),
            // The session id the client must echo back on every request.
            MCP_SESSION_ID.clone(),
            // SSE stream resumption after a dropped connection.
            LAST_EVENT_ID.clone(),
        ])
        // `WWW-Authenticate` so the client's JavaScript can read a
        // challenge; `Mcp-Session-Id` so it can read the session id the
        // server assigned on `initialize` (both are otherwise invisible to
        // `fetch`, per the CORS-safelisted response header list).
        .expose_headers([WWW_AUTHENTICATE, MCP_SESSION_ID.clone()]))
}

fn with_cors(app: Router, client_origin: &str) -> Result<Router, InvalidHeaderValue> {
    Ok(app.layer(cors_for(client_origin)?))
}

fn header<'a>(headers: &'a axum::http::HeaderMap, name: &str) -> &'a str {
    headers.get(name).unwrap().to_str().unwrap()
}

#[tokio::main]
async fn main() {
    let app = with_cors(Router::new(), "https://client.example.com").unwrap();

    // Drive an actual preflight `OPTIONS` request through the layered
    // router, so this snippet is exercised rather than only compiled.
    let preflight = Request::builder()
        .method(Method::OPTIONS)
        .uri("/mcp")
        .header("origin", "https://client.example.com")
        .header("access-control-request-method", "POST")
        .header(
            "access-control-request-headers",
            "authorization,content-type,mcp-protocol-version,mcp-session-id,last-event-id",
        )
        .body(Body::empty())
        .unwrap();
    let response = app.clone().oneshot(preflight).await.unwrap();
    assert!(response.status().is_success());

    let allow_headers =
        header(response.headers(), "access-control-allow-headers").to_ascii_lowercase();
    for h in [
        "authorization",
        "content-type",
        "mcp-protocol-version",
        "mcp-session-id",
        "last-event-id",
    ] {
        assert!(allow_headers.contains(h), "missing {h}");
    }
    let allow_methods = header(response.headers(), "access-control-allow-methods");
    for m in ["GET", "POST", "DELETE", "OPTIONS"] {
        assert!(allow_methods.contains(m), "missing {m}");
    }

    // `Access-Control-Expose-Headers` is sent on the actual response, not
    // the preflight.
    let actual = Request::builder()
        .method(Method::POST)
        .uri("/mcp")
        .header("origin", "https://client.example.com")
        .body(Body::empty())
        .unwrap();
    let response = app.oneshot(actual).await.unwrap();
    let expose_headers =
        header(response.headers(), "access-control-expose-headers").to_ascii_lowercase();
    assert!(expose_headers.contains("www-authenticate"));
    assert!(expose_headers.contains("mcp-session-id"));
}

No allow_credentials is needed here: a bearer token your JavaScript sets in the Authorization header is not a CORS credential (a cookie or browser-managed HTTP auth would be). Use an explicit origin, as above, rather than a wildcard — and never combine a wildcard allow_origin with allow_credentials(true) at all; the Fetch spec forbids it.

§Readiness and liveness probes

OAuthValidator::is_ready() is true once at least one signing key is held; OAuthValidator::key_set_status() returns the detail (key count, the JWKS URL in use, when a refresh was last attempted and last succeeded, and the last refresh error). Neither does any I/O or waits on a refresh in flight, so a probe can poll them as often as it likes. refresh_now(), by contrast, fetches every time: keep it out of probes.

use std::sync::Arc;

use axum::{Router, extract::State, http::StatusCode, routing::get};
use oauth_resource_server::OAuthValidator;

/// Readiness: this process can validate a token, because it holds at least
/// one signing key.
async fn ready(State(oauth): State<Arc<OAuthValidator>>) -> StatusCode {
    if oauth.is_ready() {
        StatusCode::OK
    } else {
        StatusCode::SERVICE_UNAVAILABLE
    }
}

/// Liveness: the process is up and answering. Nothing about the
/// authorization server belongs here.
async fn live() -> StatusCode {
    StatusCode::OK
}

/// Merge these OUTSIDE the auth layer, like `metadata_router`: a probe
/// carries no token. For the same reason the handlers take no `Credential`
/// or `AuthorizedToken` extractor, which answers 500 on a route no
/// `AuthLayer` covers.
fn probes(oauth: Arc<OAuthValidator>) -> Router {
    Router::new()
        .route("/readyz", get(ready))
        .route("/livez", get(live))
        .with_state(oauth)
}
  • Readiness means keys are held. A process with no key refuses every token with a 401, so it should not be sent traffic yet. Once ready, it stays ready: a failed refresh keeps the keys already held, so an identity-provider outage after startup does not flip it back (tokens signed by those keys still validate). If the first load fails, the background task retries after 5 s, doubling to at most 5 minutes, until keys load; that schedule, not request traffic, is what makes the process ready once the identity provider is reachable.
  • A seeded validator is ready before it has talked to anyone. With initial_jwks, is_ready() is true from the moment the validator is built, before any contact with the authorization server (key_set_status().last_success stays None until a fetch succeeds). The seeded keys are withdrawn only by a successful fetch that yields at least one usable key; a failed or empty fetch keeps them.
  • Gating on /readyz needs spawn_background_refresh() (or at the very least a refresh_now() at startup). Without it keys are loaded only when a request brings a token, and a process that is not ready receives no requests: it would stay not-ready forever.
  • Liveness must not depend on the identity provider. A liveness failure restarts the process, and a restart cannot fix an unreachable identity provider: it throws away keys that were still good, and every replica restarting at once during an outage turns the provider’s problem into a full outage of your service too, followed by a burst of key fetches as they all come back.
  • KeySetStatus::last_error’s Display names the issuer and JWKS URLs (with any userinfo or query redacted, as in KeySetStatus::jwks_uri) and repeats upstream error text: fine for logs and an internal status page. On a public endpoint, report RefreshError::kind() (discovery, fetch, parse, no_usable_keys) instead.

§Observability

Every authentication decision is logged with a fixed set of structured fields, validation and key refreshes run in tracing spans, and the optional metrics feature counts both. The field names, span names, metric names and every value listed below are stable: covered by semver like the public API. Renaming one, or moving an outcome from one value to another, is a breaking change; adding a value (a new InvalidTokenKind label, say) is not. Message text stays unstable (see MSRV and semver policy), so build dashboards and alerts on these fields, never on the sentence.

No field, span or label ever carries a token, a secret, a claim value, a URL query or a request body: every value comes from a closed set, except the bounded ones called out below.

The log fields and oauth_rs_requests_total come from the layers, RequireScopes, McpToolScopes and the axum extractors. An application that calls the framework-free authenticate() or OAuthValidator::validate itself gets the spans and the key-set metrics, but no auth.* fields and no request counts: it logs and counts its own decisions.

§Log fields

Each auth-outcome event of both layers, RequireScopes, McpToolScopes and the axum extractors (AuthorizedToken, Credential, StaticTokenMatch, Scoped) carries these fields next to its existing ones (path, reason, required, …), which are unchanged:

FieldValuesPresent on
auth.outcomeaccepted, rejected, passed_throughevery auth-outcome event
auth.mechanismstatic, oauth, noneevery auth-outcome event
auth.reasonan InvalidTokenKind::as_str() label (too_large, not_jwt, malformed_header, critical_header, algorithm_not_allowed, type_not_allowed, key_not_found, key_set_unavailable, malformed_token, bad_signature, expired, not_yet_valid, wrong_issuer, wrong_audience, missing_claim, malformed_claim, sender_constrained, client_not_allowed, token_too_old, claim_mismatch, static_token_mismatch, no_mechanism, oauth_token_required, static_token_required, other), missing, insufficient_scope, or misconfiguredrejected only
auth.status401, 403, 500 (an integer)rejected only
auth.static_labelthe matched static token’s label (1–64 visible ASCII characters, validated by StaticTokens::with)an accepted static token with a label

auth.mechanism names the credential that was judged: static for a static token (accepted, refused by a scope requirement, or not matching on a static-only layer), oauth for an OAuth access token (including a credential that was neither, when OAuth is configured: the refusal comes from the validator), and none when no credential was presented or none could be checked. misconfigured marks the error-level “Server misconfiguration” events: wirings no request can satisfy, such as a route no layer covers, a required extractor or scope behind allow_unauthenticated (with no enforcing layer around it), an AuthorizedToken or Scoped extractor or scopes behind a layer with no validator, and a StaticTokenMatch extractor behind a layer with no static token.

The events, at the levels the module docs list (targets oauth_resource_server::axum and oauth_resource_server::http_layer):

MessageLevelauth.outcome
OAuth bearer auth accepteddebugaccepted
Static bearer auth accepteddebugaccepted
No credential presented; optional auth passes the request throughdebugpassed_through
No bearer credential presenteddebugrejected (missing, with OAuth configured)
OAuth bearer auth rejected / Bearer auth rejectedwarnrejected (including a layer’s 403 insufficient_scope for the validator’s own required scopes, which the validator also logs at info: OAuth token is valid but lacks the required scope)
The credential lacks the scopes this route requires / … this handler requiresinforejected (insufficient_scope, 403)
Server misconfiguration: …errorrejected (misconfigured)

An allow_unauthenticated layer logs nothing per request (the metrics feature counts it as passed_through). A refusal now reads:

WARN oauth_resource_server::axum: OAuth bearer auth rejected path=/v1/things reason=Invalid(InvalidToken { kind: Expired, detail: "token rejected: ExpiredSignature" }) auth.outcome="rejected" auth.mechanism="oauth" auth.reason="expired" auth.status=401

§Spans

SpanLevel, targetFields
oauth_rs.validatedebug, oauth_resource_server::validatorkid, alg, auth.outcome (accepted, rejected), auth.reason
oauth_rs.validate_cacheddebug, oauth_resource_server::validatoras above; auth.outcome is also needs_key_fetch (the cache-only first pass found no key, and oauth_rs.validate follows)
oauth_rs.jwks_refreshinfo, oauth_resource_server::jwksjwks.host, result (success, discovery, fetch, parse, no_usable_keys), keys (held afterwards)
oauth_rs.jwks_discoveryinfo, oauth_resource_server::jwksissuer.host, jwks.host, result (success, discovery)

kid and alg come from the token’s unverified header, so they are attacker-chosen: they are cut to 128 characters and every character outside printable ASCII is escaped (\u{1b}), so no control character, ANSI escape or bidi override reaches a terminal or a log store raw, whatever the subscriber. They are recorded only for a credential under the 16 KiB cap that has three segments. alg is the crate’s own label (RS256, EdDSA, …) when it names an algorithm this crate knows; otherwise, and whenever the header cannot be parsed at all (alg: none, HS256, an unknown name), both are the raw strings from the header’s JSON, bounded and escaped the same way — absent only when the header is not base64url JSON or the member is not a string. The hosts are the host alone, never a scheme, port, path, credential or query. A refresh a request triggers is a child of that request’s oauth_rs.validate span, so a trace shows a request waiting on a slow authorization server. When a span’s level is disabled nothing is formatted or allocated for it: the validation path makes the same allocations as without the spans.

§Metrics (feature metrics)

With the metrics feature, this crate reports through the metrics facade; install any recorder or exporter (metrics-exporter-prometheus, say) in your application. Without the feature there is no metrics dependency and nothing is recorded.

MetricTypeLabels
oauth_rs_requests_totalcounterstage (layer, route, handler), and outcome, mechanism, reason with the log fields’ values; reason is none for accepted and passed_through
oauth_rs_jwks_refresh_totalcounterissuer_host, and result: success, discovery, fetch, parse, no_usable_keys
oauth_rs_jwks_keysgaugeissuer_host: the usable keys that validator holds, set when it is built (so keys seeded with initial_jwks show at once) and after every refresh

oauth_rs_requests_total counts decisions, not requests. stage says which check decided: layer is either authentication layer’s admission (AuthLayer, HttpAuthLayer, an allow_unauthenticated one included), exactly one per request per layer, so stage="layer" is the request count; route is a refusal by RequireScopes or McpToolScopes; handler is a refusal by an axum extractor (AuthorizedToken, Credential, StaticTokenMatch, Scoped). A request a layer accepts and a route then refuses counts once under layer (accepted) and once under route (rejected). Route and handler checks count only refusals.

issuer_host is the configured issuer’s host alone (no scheme, port, path, credential or query): one value per validator, bounded by your configuration, never by traffic. Two validators for the same host share a series. The names are also constants in the observability module, whose describe_metrics() registers HELP text and units with the installed recorder. The oauth_rs_ prefix is fixed rather than configurable: a stable name is what dashboards and alerts key on, and an exporter or Prometheus’ metric_relabel_configs can still rename it.

Some queries (Prometheus):

# Refusals per second by stage and reason, leaving out first-contact 401s.
sum by (stage, reason) (rate(oauth_rs_requests_total{outcome="rejected", reason!="missing"}[5m]))

# Share of requests the layer refused because the signing keys could not be
# loaded. Both sides are `stage="layer"`: counts are per decision, and only
# the layer stage sees every request exactly once.
sum(rate(oauth_rs_requests_total{stage="layer", reason="key_set_unavailable"}[5m]))
  / sum(rate(oauth_rs_requests_total{stage="layer"}[5m]))

# Alert: a key refresh failing, or no signing key held at all, per issuer.
sum by (issuer_host) (increase(oauth_rs_jwks_refresh_total{result!="success"}[1h])) > 0
oauth_rs_jwks_keys == 0

§Configuration reference

OAuthConfig is the unvalidated input. OAuthConfig::resolve checks it and returns a ResolvedOAuthConfig, which is what the validator is built from. With the serde feature every field is optional in the input and unknown keys are refused. The names below are bare field names; KeyNaming decides how error messages spell them (KeyNaming::Dotted("oauth") gives oauth.issuer, KeyNaming::Env("MYAPP_OAUTH_") gives MYAPP_OAUTH_ISSUER).

FieldTypeDefaultMeaning
enabledboolfalseMaster switch. While false, resolve returns Ok(None) and checks nothing else.
issuerStringrequiredThe authorization server’s issuer identifier. Compared byte for byte with each token’s iss and published in the metadata document. Copy it from the server’s discovery document, including any trailing slash. Must be an absolute URL with no query, fragment, space, control or non-ASCII character, and https (RFC 8414 §2) unless its host is loopback or allow_insecure_http is set, however it is spelled (http:/host is plain http too). Write it https://host/…: a spelling the URL parser has to repair (https:/host, a \ in the host) is accepted but logged as a startup warn, and can never match a token’s iss.
jwks_uriOption<String>NoneWhere to fetch the signing keys. When absent or blank, it is discovered from the issuer’s metadata (OpenID Connect Discovery, then RFC 8414), and the discovered document’s issuer must equal issuer exactly. Same URL rules as issuer, except that a query is allowed.
audienceStringrequired, unless audiences is setA value the token’s aud must contain. There is no default; see Audience.
audiencesVec<String>[]More accepted audiences. A token matching any configured value passes. Useful while migrating from one audience to another.
resourceStringrequiredThis API’s public URL, e.g. https://api.example.com/v1. Published as resource in the metadata and used to build the metadata URL. Not compared with aud unless you also list it in audience or audiences. Same URL rules as issuer (https per RFC 9728 §1.2: clients send their bearer tokens to it).
required_scopeOption<String>NoneA scope every token must carry, or it gets 403. One RFC 6749 §3.3 scope-token (printable ASCII, no space, " or \), matched exactly and case-sensitively. An explicit empty value is an error, not “no scope”.
required_scopesVec<String>[]More scopes every token must carry: all of them, together with required_scope. With neither set there is no scope check at all, which resolve accepts only with require_at_jwt or allow_unscoped_tokens; see ID tokens.
scopes_supportedOption<Vec<String>>None, which resolves to the required scopesThe scopes advertised in the metadata document and in the 401 challenge. Declarative only; each entry a scope-token. List every required scope here if you set it: clients request what is advertised, and a required scope they are not told about means 403 on every call. An explicit [] advertises nothing: the metadata omits scopes_supported (RFC 9728 §3.2) and the 401 names the required scopes instead. This default is identical on the serde and env paths — resolve applies it either way, so a serde user who sets only required_scope gets the same advertised scopes an env user does. The one path-specific difference is in Environment variables: the env loader cannot express an explicit empty list.
scope_claimsVec<String>["scope", "scp"]The claims scopes are read from. Each is read as a space-delimited string or an array of strings, and the results are combined. Must not be empty.
principal_claimsVec<String>["preferred_username", "sub"]Claims tried in order to name the caller in logs (AuthorizedToken::principal). email is left out so addresses do not reach logs unless you add it.
algorithmsVec<String>RS256 RS384 RS512 PS256 PS384 PS512 ES256 ES384 EdDSAThe signature algorithms a token may use, as case-sensitive JWS names. HS256, HS384, HS512 and none are always refused. A JWKS key on a curve the verifier cannot use (P-521, whose ES512 ring does not verify) or not meant for signatures (X25519, X448) is skipped whatever this lists, as is an RSA key whose modulus is not 2048 to 8192 bits.
leeway_secsu6460Clock-skew allowance for exp and nbf, and for max_token_age_secs’ iat checks. At most MAX_LEEWAY_SECS (300); a larger value is an error, not clamped.
require_at_jwtboolfalseRequire the token header’s typ to be at+jwt (or application/at+jwt), as RFC 9068 §4 requires. Off is a deliberate, documented deviation; see ID tokens. Turn it on if your server emits it.
allow_unscoped_tokensboolfalseAccept a config with no required scope and require_at_jwt off. Without it, resolve refuses that combination, because it would accept OIDC ID tokens as access tokens.
allow_insecure_httpboolfalseAccept a plain-http issuer, jwks_uri or resource on a non-loopback host, for an in-cluster address on a trusted network. Also governs a jwks_uri discovered from a plain-http issuer and every redirect followed while fetching keys. Loopback hosts never need it.
accept_static_bearerbooltrueWhether a separately configured static token keeps working while OAuth is on. Read only by static_token_policy.
allowed_client_idsVec<String>[] (no check)The OAuth clients whose tokens are accepted. A token’s client is its client_id claim (RFC 9068 §2.2), else its azp (the first that is a non-empty string, as AuthorizedToken::client_id reads it), and it must equal an entry byte for byte; client_id wins, so a listed azp does not rescue an unlisted client_id, and a client_id that is present but empty or not a string is refused rather than read past. No client, or another one, is a 401 (ClientNotAllowed). Use it where the audience is shared by several clients (see Client identity). No blank entries.
max_token_age_secsOption<u64>None (no check)Refuse a token issued more than this many seconds ago (now - iat, plus leeway_secs of slack): TokenTooOld. With it set, iat is required (MissingClaim) and must be a NumericDate (MalformedClaim), and an iat more than leeway_secs in the future is NotYetValid. 1 to MAX_TOKEN_AGE_SECS (2 592 000, 30 days); 0, or more than that, is an error. All 401.
required_claimsmap of claim name to value{} (no check)Claims every token must carry with a value: the token’s claim must equal it (JSON equality, so "1" is not 1), or be an array containing it (for groups, roles and the like). A missing claim is MissingClaim; any other value (including null, an object, or an array without it) is ClaimMismatch; both 401. Values must be a string, number or boolean (an array may later mean “any of”, additively); naming a scope claim or azp/client_id logs a startup warn; top-level claims only (no paths into nested objects); names must not be blank nor one of iss, aud, exp, nbf, iat and cnf, which this crate already checks.

resolve is all-or-nothing. An enabled config either resolves completely or fails with a ConfigError listing every problem, each naming its setting:

oauth.enabled is true but the OAuth config is not usable:
  - these required settings are empty: oauth.issuer, oauth.resource, oauth.audience (or oauth.audiences). Set issuer to ...
  - oauth.leeway_secs 3600 is over the 300-second cap — leeway is for clock drift, not for extending token lifetimes
Fix these, or set oauth.enabled: false.

The same problems are available structured, so code can react to them without reading the sentences. ConfigError::problem_details() returns ConfigProblems, each with a kind() (a #[non_exhaustive] ProblemKind, with a stable as_str() label), the keys() it names (already spelled through your KeyNaming) and its message():

use oauth_resource_server::{KeyNaming, OAuthConfig, ProblemKind};

let err = OAuthConfig { enabled: true, leeway_secs: 3600, ..OAuthConfig::default() }
    .resolve(KeyNaming::Env("MYAPP_OAUTH_"))
    .unwrap_err();
for problem in err.problem_details() {
    match problem.kind() {
        ProblemKind::MissingRequired => eprintln!("set one of {:?}", problem.keys()),
        ProblemKind::LeewayTooLarge => eprintln!("lower {}", problem.keys()[0]),
        _ => eprintln!("{problem}"), // keep a wildcard arm: the enum grows
    }
}

ConfigError::problems (plain strings) stays for compatibility and is filled from the same list, in the same order. Editing that field in place does not update problem_details(). ConfigError::from_problems builds an error from ConfigProblems, and ConfigProblem::from(String) (kind Other) lets your own loader mix its problems in; ConfigError::new still takes plain strings.

ResolvedOAuthConfig has one setting you may want to set by hand that OAuthConfig does not: resource_name, a human-readable name published in the metadata document. Set it on the resolved value if you want one. (It also carries key_naming, the owned copy of the KeyNaming it was resolved with, which the validator’s log lines use; and its required_scopes holds the union of required_scope and required_scopes.)

§Fetch options (code, not config)

How the validator reaches the authorization server is set on OAuthValidator::builder(&resolved), not in OAuthConfig (whose fields are a config format; these are wired in code). OAuthValidator::new(&resolved) is the builder with nothing set. Every option is checked by build(), and a refused one is a ValidatorError, never silently dropped.

Builder methodDefaultMeaning
add_root_certificate_pem(&[u8])noneAdds the certificate(s) in a PEM file (one, or a bundle) as TLS trust anchors, on top of the TLS feature’s own roots. Works with every TLS feature. Pass CA certificates only. Error: ValidatorError::InvalidRootCertificate (no certificate in it, one the backend cannot use, or a private-key block).
proxy(url)reqwest’s own proxy handling (environment variables, and system settings where reqwest’s system-proxy feature is on), for non-loopback fetchesAn explicit http:// or https:// proxy for every non-loopback metadata and JWKS fetch, user:password@ sent as proxy Basic auth. Setting it turns the environment and system proxies off, NO_PROXY included. No path, query, fragment, SOCKS scheme, space, control or non-ASCII character, and no credential in a plain-http proxy URL on a non-loopback host unless allow_insecure_http is set. Error: ValidatorError::InvalidProxy (never showing the URL).
fetch_timeout(Duration)DEFAULT_FETCH_TIMEOUT, 10 sTimeout for one metadata or JWKS request, connect through last body byte. MIN_FETCH_TIMEOUT (1 s) to MAX_FETCH_TIMEOUT (60 s); zero or anything outside is ValidatorError::FetchTimeoutOutOfRange.
initial_jwks(&str)noneA JWK Set ({"keys": [...]}) the key cache starts with, parsed and narrowed exactly like a fetched one (256 KiB and 64-key caps, no oct/use: enc/non-verify key, each key limited to the configured algorithms). Refreshed normally: the first successful refresh replaces it, a failed one keeps it. Error: ValidatorError::InvalidInitialJwks.

Every fetch whose URL is on a loopback host (localhost, *.localhost, 127.0.0.0/8, ::1) uses a separate client with no proxy of any kind, so a loopback fetch never goes through a proxy, explicit or from the environment. localhost and *.localhost are recognized by name, so that client also resolves every name itself, to ::1 and 127.0.0.1 (keeping the URL’s port), never through DNS: some resolvers (musl, glibc without nss-myhostname, some container DNS servers) forward *.localhost upstream, where whoever controls that DNS could otherwise receive a cleartext, proxy-free key fetch. A fetch that starts on a loopback URL may not be redirected off loopback at all. Every other fetch uses reqwest’s own proxy handling untouched: the HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY environment variables (and, on macOS and Windows, the system proxy settings when reqwest’s system-proxy feature is on in your build) exactly as a plain reqwest client applies them — unless proxy(url) is set, which turns all of those off.

With initial_jwks, is_ready() is true from the start and key_set_status() reports the seeded keys in keys, while last_attempt and last_success stay None until a real fetch (keys > 0 with no last_success means “seeded, not yet confirmed by the authorization server”). Seeded keys count as held, so a failed background pass retries on the held-keys schedule (a minute, backing off to an hour), not the keyless 5-second one. To seed from a file, pass std::fs::read_to_string(path)?.

use std::time::Duration;

use oauth_resource_server::{OAuthValidator, ResolvedOAuthConfig, ValidatorError};

fn build_validator(resolved: &ResolvedOAuthConfig) -> Result<OAuthValidator, ValidatorError> {
    OAuthValidator::builder(resolved)
        .proxy("http://proxy.example.com:3128")
        .fetch_timeout(Duration::from_secs(5))
        .build()
}

§Embedding OAuthConfig in your own config

OAuthConfig derives #[serde(deny_unknown_fields)] (feature serde) so a typo’d or renamed setting fails at startup instead of being silently ignored. Nest it under its own field in your application’s config struct; do not #[serde(flatten)] it.

#[serde(flatten)] buffers the input through a generic value first, and that buffering is what makes deny_unknown_fields stop firing for the struct it is applied to: a field neither struct recognizes is silently dropped instead of failing to deserialize at all, defeating the whole point of the attribute. (This happens before OAuthConfig::resolve ever runs, so it is a plain serde deserialization error from your format crate, not this crate’s own ConfigError.) This is a general serde limitation, not specific to this crate — see serde’s own flatten docs.

use oauth_resource_server::OAuthConfig;
use serde::Deserialize;

#[derive(Deserialize, Default)]
struct FlattenedConfig {
    #[serde(flatten)]
    oauth: OAuthConfig,
}

#[derive(Debug, Deserialize, Default)]
struct NestedConfig {
    #[serde(default)]
    oauth: OAuthConfig,
}

// Flattened: a typo'd field (`isuer` for `issuer`) is silently accepted.
// `deny_unknown_fields` does not fire through `#[serde(flatten)]`.
assert!(serde_yaml_ng::from_str::<FlattenedConfig>("isuer: x\n").is_ok());

// Nested instead of flattened: the same typo is refused, as intended.
let err = serde_yaml_ng::from_str::<NestedConfig>("oauth:\n  isuer: x\n").unwrap_err();
assert!(err.to_string().contains("unknown field `isuer`"));

§Environment variables

The env feature’s oauth_config_from_env(prefix) reads each field from <PREFIX><FIELD> in upper case (MYAPP_OAUTH_REQUIRED_SCOPES), and every one of them also accepts the _FILE form. It differs from the serde path in these ways:

  • Whether OAuth is on is inferred. <PREFIX>ENABLED may be true or false. When it is unset, OAuth is on if any of ISSUER, JWKS_URI, AUDIENCE, AUDIENCES or RESOURCE is set, and off (Ok(None)) otherwise.
  • SCOPES_SUPPORTED cannot be explicitly empty. An empty value reads as unset, so it always resolves to the required scopes, exactly as an omitted scopes_supported does on the serde path — OAuthConfig::resolve applies that default the same way regardless of which path produced the config. This is the only behavioral difference between the two paths for this setting: neither defaults it to the required scopes “instead of” the other leaving it empty.
  • Lists (AUDIENCES, REQUIRED_SCOPES, SCOPES_SUPPORTED, SCOPE_CLAIMS, PRINCIPAL_CLAIMS, ALGORITHMS, ALLOWED_CLIENT_IDS) are split on whitespace.
  • Booleans accept exactly true or false. Anything else is an error, so a typo cannot silently read as false.
  • LEEWAY_SECS and MAX_TOKEN_AGE_SECS are decimal integers (MAX_TOKEN_AGE_SECS unset means no age check).
  • REQUIRED_CLAIMS is one JSON object: MYAPP_OAUTH_REQUIRED_CLAIMS='{"tid": "<tenant id>", "groups": "api-users"}'. Anything but a JSON object is an EnvParse problem; an object naming one claim twice is InvalidRequiredClaim, never “the last one wins” (a config file naming one twice fails to deserialize, for the same reason).
  • Values are trimmed. A variable that is empty after trimming counts as unset — except REQUIRED_SCOPE: set to whitespace only, it is BlankRequiredScope, as a blank required_scope in a config file is, never “no scope”. A _FILE whose contents are empty after trimming is an error. Setting both VAR and VAR_FILE is an error.
  • A _FILE must be a regular file of at most 64 KiB (env::MAX_SECRET_FILE_BYTES). A directory, FIFO or device (/dev/zero) is refused before it is opened (EnvError::NotAFile), and a bigger file after reading one byte past the limit (EnvError::FileTooLarge), so a wrong path can neither block startup nor exhaust memory.
  • Every problem is reported at once. Load and parse problems are listed in one ConfigError together with the problems resolve finds. Loader problems have kind ProblemKind::EnvLoad (a variable or _FILE that could not be read) or ProblemKind::EnvParse (a value that did not parse), and keys() names the variables to look at (VAR and VAR_FILE when both were set, VAR_FILE when its file could not be used, the variable itself for a parse problem); EnvOAuthConfig::problem_details() lists them before resolve. A problems entry you edit or add on the loaded value reaches the ConfigError as ProblemKind::Other.

To apply your own defaults before validation, call unresolved_oauth_config_from_env, change the returned config, then call resolve() on it.

A static API key is not an OAuthConfig field; it is read on its own:

FunctionVariablesReturns
secret_from_env(var)<VAR> or <VAR>_FILEthe one key, or None
static_tokens_from_env(var)the above, plus <VAR>_NEXT or <VAR>_NEXT_FILEa StaticTokens set: "current", and "next" while a rotation is under way (one entry when the two are equal), or None; <VAR>_NEXT without <VAR> is an error

Both trim values, treat a blank variable as unset, refuse an empty, oversized or not-a-regular _FILE, and refuse a variable set together with its _FILE. See Rotating a static API key.

§Security model

§What is checked, in order

For each candidate credential, OAuthValidator::validate:

  1. Refuses what it can from the unverified header, before any key lookup: an empty credential (Missing), one over 16 KiB, one that is not three dot-separated segments (not a JWT), a header that does not parse (a kid that is present but not a string, "kid": null included, is one), a header that lists critical extensions in crit (RFC 7515 §4.1.11: this crate understands none, so any crit, even an empty one, is refused), an alg not in algorithms, and a typ that is not an access-token type. Junk never causes a request to the authorization server.
  2. Finds the key: the JWKS entry whose kid matches and whose key type can produce the token’s alg. A token with no kid uses the only compatible key, and is refused if there are several. An unknown kid triggers a key refetch at most once a minute.
  3. Verifies the signature, iss, aud, exp and nbf in one jsonwebtoken::decode call, so no claim check can run apart from the signature check. exp, iss and aud must be present; nbf is checked when present; leeway_secs applies to both times (and to the iat checks of max_token_age_secs).
  4. Re-checks the claims the decoder does not police: iss must be a single string equal to issuer, byte for byte (the decoder alone would accept an array containing it); a present nbf must be a NumericDate, a non-negative number (the decoder silently skips anything else, which would let a not-yet-valid token through); and a token carrying a cnf confirmation claim is refused, since it is bound to a DPoP key (RFC 9449) or an mTLS certificate (RFC 8705) whose proof this crate cannot check, and those RFCs forbid accepting it as a plain bearer token.
  5. Applies the optional claim policy, each check off unless configured, in this order: the client (client_id, else azp) must be in allowed_client_ids; iat must be no older than max_token_age_secs (and not in the future); every required_claims entry must match. These read only claims the signature already covered, and each refusal is a 401 with its own kind (ClientNotAllowed, TokenTooOld, NotYetValid, MissingClaim, MalformedClaim, ClaimMismatch).
  6. Checks scopes. The token must carry every required scope, or it is refused with InsufficientScope (403), not 401. A token that fails step 5 is a 401 whatever its scopes.

The result is an AuthorizedToken (subject, principal, scopes, plus the verified claims and token metadata) or a TokenRejection (Missing, Invalid(InvalidToken) or InsufficientScope; an InvalidToken’s kind() names the check that failed).

§Guarantees

  • Algorithm confusion is closed twice. No configuration can enable HMAC or none; Algorithm has no variant for either. Independently, each key is limited to the algorithms its own type, and its declared alg if it has one, can produce. A token cannot steer an RSA key into an ECDSA check, or any key into HMAC. Symmetric (oct) keys, use: enc keys, and keys whose key_ops do not include verify are never used.
  • Every failure fails closed. An unreachable server, a malformed key set, an unknown kid during the refetch cooldown and a TLS failure all mean “refuse”, never “skip the check”. A failed refresh keeps the keys already held, so an outage at the authorization server does not also revoke keys that are still good. A refetch runs in a task of its own, so a caller that disconnects or times out mid-fetch cannot cancel it and leave the cooldown spent with no keys loaded.
  • The middleware fails closed by construction. AuthLayer::builder().build() (and the tower layer’s HttpAuthLayer::builder().build(), which runs the same check) returns an error with neither a static token nor a validator. The only pass-throughs are AuthLayer::allow_unauthenticated() and a StaticTokenDecision::Unauthenticated handed to AuthLayer::from_decision or build_with_decision (which static_token_policy returns only with allow_unauthenticated = true); both must be asked for by name.
  • Optional authentication only skips what was never presented. An optional() layer passes a request through only when every value of every configured credential header is absent or blank. Blank means empty or whitespace, Bearer followed by nothing, or another scheme in a Bearer source. A header value that is not visible ASCII, a non-blank later value of a repeated header, any DPoP-scheme value, and Bearer followed by a tab and a token all count as presented. Anything presented gets exactly the refusal a non-optional layer sends (401 for an invalid, expired or unknown credential, 403 for insufficient scope, with the same challenge and on_reject body). A caller gains nothing it could not get by leaving its credential headers off, so a handler behind an optional layer must treat None as unauthenticated. optional() still needs a static token or a validator to build. It also removes any credential an outer layer inserted, so None after its pass-through is never replaced by an outer layer’s token.
  • Per-route scopes fail closed. A layer’s require_scopes, RequireScopes, Scoped and McpToolScopes refuse a token without the scopes with 403 and a challenge naming them, and a static token (it has none) the same way unless static_token_bypasses_scopes() says otherwise. RequireScopes, Scoped and McpToolScopes answer 500 (logged at error) on a route no authentication layer covers, even when they require nothing. McpToolScopes classifies the body of a request under any method, reads at most its body limit (1 MiB by default) and refuses a larger body with 413; a body it cannot classify with certainty (not JSON, a tools/call with no readable tool name, a repeated member) needs every scope any tool requires, never fewer; a JSON-RPC batch needs every scope any of its calls needs; it never logs body content. Its tool names are matched exactly (see Per-tool scopes): an MCP server whose dispatcher normalizes names (case, whitespace) must not be put behind it with scopes on some tools and not others.
  • The extractors fail closed. Credential and AuthorizedToken refuse a request the layer inserted nothing into with that layer’s own 401 and challenge, built by the same code as its other refusals. On a route no AuthLayer covers, they and their Option<..> forms answer 500 and log at error. They never read that case as anonymous. A required extractor behind allow_unauthenticated(), or an AuthorizedToken extractor behind a layer with no validator, is a 401 no credential can satisfy, and is also logged at error.
  • Every refusal carries a challenge. With OAuth configured, every 401 and 403 carries WWW-Authenticate with resource_metadata, including a request that failed with a static token, since the server cannot tell which credential the caller meant. A custom on_reject cannot remove it, and a config whose challenge would not be a valid header makes AuthLayerBuilder::build fail rather than send challenge-less 401s (the validator itself then logs at error and falls back to a minimal Bearer error="…" challenge — its scope only when that is valid, no resource_metadata — so refusal() never returns a header a line break could split). With only a static token, 401s carry Bearer error="invalid_token" unless the application opts out with static_challenge(None). The status and the challenge are decided in one place: the crate-private decision behind the public refusal() is the same one the axum layer and the tower layer call, so a hand-built integration on refusal() and either layer cannot disagree about a refusal.
  • Weak configurations are refused, not just logged. resolve refuses a plain-http issuer, jwks_uri or resource on a non-loopback host, a config that would accept ID tokens (no required scope and no typ check), a scope that is not a valid scope-token, and a URL with a space, control or non-ASCII character. Whether a URL is plain http is decided on the URL as parsed, so http:/host or HTTP:\\host needs the opt-in exactly as http://host does. The first two have explicit opt-ins (allow_insecure_http, allow_unscoped_tokens).
  • Network use is bounded. Only the configured jwks_uri, the discovery URLs derived from the configured issuer, or (with no jwks_uri configured) the jwks_uri that the issuer’s own metadata document names are ever fetched, plus any redirects from those; nothing in a token influences where a request goes. A discovered jwks_uri may be on any host the issuer’s metadata names, but that document is used only when its issuer equals the configured one byte-for-byte, and it may not downgrade an https issuer to http. Responses are capped at 256 KiB and 64 keys, fetches time out after 10 seconds (1 to 60 s with fetch_timeout), only a 2xx answer is read, at most three redirects are followed, a redirect from https to http is refused, one to plain http on a non-loopback host is refused without allow_insecure_http, and a fetch that starts on a loopback URL may not be redirected off loopback (it runs on the proxy-free client). A discovered jwks_uri whose fetch fails is discovered again on the next refresh, so an authorization server that moves its key set is followed without a restart. None of the fetch options loosens any of this, and a key set passed to initial_jwks gets the same size cap, key cap and per-key checks as a fetched one.
  • Proxies and extra roots do not weaken key integrity. An https fetch through a proxy (explicit, or from HTTPS_PROXY) is a CONNECT tunnel: TLS runs end to end to the authorization server and its certificate is checked against its host name, so the proxy can neither read nor replace the keys. That is why a plain-http proxy on a non-loopback host needs no opt-in; a plain-http fetch through it already needed allow_insecure_http for its own URL, and a fetch of a loopback URL never goes through any proxy, explicit or from the environment. The proxy-free client is chosen from the URL a fetch starts at; a redirect from a loopback URL to a non-loopback one is refused, so that client never carries a hop the operator’s proxy should have seen, and it resolves every name to loopback itself, so localhost/*.localhost — exempted by name — never reach DNS. One residual: a redirect from a non-loopback URL to a loopback one is followed by the normal client and, with an environment or system proxy (never an explicit one, which skips loopback hosts), can go through it, as it always has. Such a hop is either https to https, where TLS stays end to end and the certificate is still checked, or plain http, which the redirect policy follows only when no https URL came before it and which already needed allow_insecure_http for any non-loopback hop. A credential in a plain-http proxy URL on a non-loopback host would cross the network in cleartext, so build() refuses it without allow_insecure_http (and logs a warn with it). The proxy URL never appears unredacted in a log line or Debug, and a refused one never appears in the error at all. A root certificate added with add_root_certificate_pem is trusted for any host name on these fetches, so whoever holds that CA’s private key can serve signing keys: add only the CA that issues the authorization server’s certificate.
  • Secrets stay out of logs. Tokens and the static tokens are never logged, and the Debug output of the layer, its builder and the policy decision redacts the static token; a StaticTokens set prints its count and labels only. Labels are not secrets: StaticTokens::with accepts only 1 to 64 visible ASCII characters, so a label is always safe in a log line. RejectContext’s Debug prints no header values, and the layer marks the configured credential headers sensitive on the request, so a tracing layer or a handler’s Debug of the headers shows Sensitive instead of the token. The token-derived kid, typ, subject and principal are truncated to 128 characters when logged, and the kid and alg span fields (from the unverified header) additionally have every character outside printable ASCII escaped; scope values are logged in full (on an insufficient-scope refusal at info, and on an accepted token at debug), bounded only by the 16 KiB credential cap. Rejection reasons go to the log, never to the client. A credential in the issuer, jwks_uri or resource URL (userinfo, or a query) is masked (***@, ?***) wherever this crate shows it: every log line, error and configuration problem, and the Debug output of OAuthConfig, ResolvedOAuthConfig, EnvOAuthConfig, AuthorizedToken, the validator, the layers and their builders. The refused request’s URI in a RejectContext Debug is its path, with any query shown as ?*** (a client may send its token there, RFC 6750 §2.3); the layers log the path only. The RFC 9728 metadata document and the challenges publish issuer and resource as configured.
  • The static token is compared in constant time (with subtle). Its length is not hidden. With several static tokens, every candidate is compared with every entry, with no early exit once one matches, and the matching entry is selected without a data-dependent branch, so the time does not depend on which entry matched. It does depend on how many entries have the candidate’s length (each comparison ends early on a length mismatch, as with one token); keys of one fixed length reveal nothing by it. A StaticTokens set, the layer builders’ single static token and the env loaders’ intermediate copies are wiped (zeroize) when dropped. Strings your code passes in or keeps, secret_from_env’s returned String, a StaticTokenDecision’s payload and the process environment are not.
  • Configuration is validated at startup, all of it at once, so a broken deployment refuses to start rather than answering 401 to everyone.

§What is not covered

  • Revocation. A JWT stays valid until exp. Revoking it at the authorization server does not stop this crate from accepting it, so keep access-token lifetimes short.
  • Opaque tokens and introspection. Only JWT access tokens are accepted; there is no RFC 7662 introspection. OAuthValidator’s documentation describes where it would plug in.
  • Sender constraint. There is no DPoP (RFC 9449) and no mTLS token binding (RFC 8705): whoever holds a bearer token can use it, so protect tokens in transit with TLS. A token the authorization server did sender-constrain (one carrying cnf) is refused, not downgraded to a bearer token. Tokens are accepted from headers only (bearer_methods_supported is ["header"]), never from query strings or form bodies.
  • Replay. There is no jti tracking: a token is usable until exp by whoever holds it. Token age can be bounded independently of exp with max_token_age_secs (off by default), which limits how long a stolen token stays useful when the authorization server issues long-lived ones.
  • Client identity, unless you list the clients. By default azp and client_id are not checked: any client of the authorization server whose tokens carry an accepted aud and the required scopes is accepted. That is the whole story when the audience identifies this API alone, but not when the authorization server stamps an audience shared by many clients (an Auth0 API identifier, an Okta custom authorization server’s audience, an Entra ID app ID URI): then every client granted that API gets in. Set allowed_client_ids to the clients you mean to serve (it reads client_id, else azp; for a client named in another claim, such as Okta’s cid, require that claim with required_claims; see the provider guide), and see Audience for what a client_id audience means. Any further claim policy (a tenant, a group) can be required with required_claims.
  • Scope hierarchies. Every scope check — the validator’s, a layer’s require_scopes, RequireScopes, Scoped, McpToolScopes — is an exact, all-of match: there is no way to say “api:write implies api:read”. Hierarchy-aware checks are the application’s (AuthorizedToken::has_scope); see Per-route and per-handler scopes.
  • Malformed requests get 401, not 400. RFC 6750 §3.1 suggests 400 invalid_request for a malformed request; this crate treats anything it cannot read as no credential. A request that repeats a credential header has only its first value read; the rest are ignored, not refused.
  • Request rate limiting. Only key refetches are rate-limited. Put your own limiter in front if you need one.
  • Live reconfiguration. Nothing hot-reloads; see Configuration changes need a restart.
  • The trust anchor. The keys are exactly as trustworthy as the connection they arrive over. A plain-http issuer or jwks_uri (or resource) on a non-loopback host is refused at startup, as RFC 8414 §2 and RFC 9728 §1.2 require https, unless you set allow_insecure_http for an in-cluster address on a network you trust; the validator then still logs a warn for each such URL. The same rule holds for URLs you did not configure: a jwks_uri discovered from a (loopback) http issuer, or a redirect followed while fetching keys, may land on plain http on a non-loopback host only with allow_insecure_http, and is warned about when it does. An https issuer’s http jwks_uri and an https→http redirect are always refused.
  • One algorithm per key. RFC 8725 §3.1 says each key is used with exactly one algorithm. A JWK that declares its alg is held to it. One that does not (alg is optional in RFC 7517) can verify any allowlisted algorithm its type produces; an RSA key without alg accepts RS256 through PS512 under the default allowlist. That is accepted because such a key set gives no other way to know the signing algorithm, and no practical attack mixing those algorithms on one key is known. The validator logs a warn naming the kid when such a key first appears; narrow algorithms to the algorithm your server signs with to bind it.
  • A withdrawn key while the key set is unreachable. Keys are dropped when a refresh succeeds without them. If every refresh fails (an outage, or someone blocking the fetches), the keys already held stay trusted, with no upper bound. That is the price of not turning an authorization-server outage into an outage of your API.

§ID tokens and the lenient typ default

With require_at_jwt off (the default), a token whose header typ is at+jwt, JWT, or absent passes, and any other type (dpop+jwt, logout+jwt, …) is refused. That departs from RFC 9068 §4, under which a resource server MUST reject any typ other than at+jwt or application/at+jwt (RFC 8725 §3.11 recommends the same explicit typing). The default is lenient because Authentik, Keycloak (by default), Microsoft Entra ID and Okta emit JWT or no typ at all, and a strict default would reject every one of their tokens.

The cost: on servers that put the client_id in aud (Authentik and Kanidm, for example), an OIDC ID token issued to the same client also has the right iss and aud. Two things keep it out:

  • A required scope. ID tokens carry no scope or scp claim, so with the default scope_claims any required scope refuses them. This is why every recipe in the provider guide sets one. If you point scope_claims at a claim ID tokens do carry, such as groups, a required value no longer keeps them out.
  • require_at_jwt: true, on servers that emit typ: at+jwt (Authelia and Kanidm, for example). It is the one check that tells an access token from an ID token.

With neither, any token the issuer signs for the audience would be accepted, ID tokens included, so resolve refuses that configuration. If it really is what you want (an application may need no more than “signed by this issuer for this audience”), set allow_unscoped_tokens: true; the validator still logs a warn when it is built.

§Audience: which value to configure

There is no default and no guessing, because a wrong guess either rejects every real token or accepts tokens meant for another service. Decode one real access token (its middle segment is base64url JSON) and look at aud. Servers fall into two groups:

  • Servers that honour RFC 8707 resource indicators or let you configure an access-token audience put the resource URL or an API identifier there. Usually that is the same value as resource. Authelia with a client audience behaves this way (verified in a sandbox), as do, according to their documentation, Auth0 API identifiers, Okta custom authorization servers, Ory Hydra and Logto API resources.
  • Servers that ignore the resource parameter and stamp the OAuth client_id need the client_id. Authentik (verified in production) and Kanidm (token shape verified) behave this way, as do, according to their documentation, Keycloak, Casdoor, Rauthy and Dex. Microsoft Entra ID stamps the API application’s own client id.

A client_id audience is weaker, and departs from the specs. RFC 9068 §4 says the resource server MUST check that aud contains an identifier it expects for itself; the MCP authorization spec (2026-07-28, “Token Handling” and “Access Token Privilege Restriction”) says an MCP server MUST accept only tokens issued specifically for it as the audience (RFC 8707 §2). A client_id identifies the client, not this API: every token that client obtains from the authorization server, for any resource, carries the same aud, and all of them pass here. It is sound only when that OAuth client is dedicated to this one resource server and shared with no other API or MCP server. Wherever your authorization server can stamp a resource URL or API identifier instead, use that.

To switch from one audience to another without downtime, list both in audiences while you migrate.

§Design rationale

  • Provider differences are configuration, never code. Authorization servers agree on signatures and on iss, aud and exp, and disagree on almost everything else: scopes as a scope string or an scp array, aud as the client_id or the resource, RS256 or ES256 or EdDSA, typ: JWT or at+jwt, a username or only a UUID sub. Each is a config field, and each supported shape has a test modeling it. There is no if provider == ... anywhere.
  • Scopes come from scope and scp by default, in every shape, combined. That default covers every shape seen in practice, and a claim a token does not carry adds nothing, so reading both cannot widen access.
  • iss is compared byte for byte, never normalized. Matching “equivalent” URLs would let a near-miss issuer start matching silently.
  • The algorithm allowlist is wide, and each key is bound to its own type. Kanidm signs ES256 by default and some servers use EdDSA, so an RS256-only default would force per-provider configuration. Binding keys to their type is what makes the wide list safe. HMAC stays refused outright.
  • Keys load in the background, not before startup. If startup waited for the authorization server, its outage would take this service down too. Keys are loaded as soon as the refresh task starts and re-read hourly; a re-read that succeeds is what drops a key the server has withdrawn. A failed pass is retried after a minute, backing off to an hour — or, while no key is held at all, after 5 s, backing off to 5 minutes, since a keyless process refuses every token and a readiness probe keeps away the traffic that would otherwise trigger a refetch. These retries are timer-driven; no request can schedule one. The task stops when the last Arc of the validator is dropped.
  • An unknown kid refetches at most once a minute. kid comes from an unverified header, so without a limit a stream of junk tokens would turn your service into an amplifier aimed at your identity provider.
  • The key lock is never held across a network call. A slow identity provider delays only requests whose key is not already cached.
  • A missing credential gets the same invalid_token challenge as a bad one. RFC 6750 §3.1 says a server should omit the error code there, but clients in use start their authorization flow from this challenge, and the resource_metadata they depend on is present either way. The difference is kept in the log level instead.
  • 401 and 403 are different answers. A client that gets 401 for a missing scope re-authorizes, gets the same token, and loops. 403 insufficient_scope names the scope it needs.

§Using with other frameworks

The validator and the credential logic have no web-framework dependency. They do need a Tokio 1.x runtime: key fetches use reqwest with a Tokio timer and run in a spawned task, and the first fetch panics outside one. On async-std, smol or another executor, drive validate and authenticate from a Tokio runtime handle.

Whatever the stack, the 401/403 decision is one function, refusal(): the axum layer, the tower layer and a hand-built integration all get the status and WWW-Authenticate challenge from the same code, so they cannot disagree. For a request that needed more scopes than the validator’s (checked with AuthorizedToken::require_scopes), refusal_for_scopes() is the same decision with a 403 challenge naming them — the bytes the layers’ per-route scope checks send.

§A tower stack (hyper, tonic, …): HttpAuthLayer

With the tower feature, oauth_resource_server::http_layer::HttpAuthLayer wraps any tower service over http::Request<ReqBody> / http::Response<ResBody>, for any body types. It is the axum layer’s check without axum: the same builder (static_token, static_tokens, oauth, sources, static_challenge, optional, require_scopes, static_token_bypasses_scopes, build_with_decision), the same fail-closed build, the same refusals, and the same Credential/AuthorizedToken/StaticTokenMatch request extensions; the http_layer::RequireScopes route layer (and McpToolScopes) work behind it exactly as behind the axum layer. A refusal’s body is ResBody::default() unless on_reject builds a response, whose status and challenge the layer then sets:

use std::convert::Infallible;
use std::sync::Arc;

use http::{Request, Response, header::CONTENT_TYPE};
use oauth_resource_server::http_layer::{HttpAuthLayer, RejectContext};
use oauth_resource_server::{Credential, OAuthValidator};
use tower::{ServiceBuilder, service_fn};

async fn handler(request: Request<String>) -> Result<Response<String>, Infallible> {
    let who = match request.extensions().get::<Credential>() {
        Some(Credential::OAuth(token)) => format!("{:?}", token.subject),
        _ => "someone".to_string(),
    };
    Ok(Response::new(format!("hello, {who}")))
}

fn service(oauth: Arc<OAuthValidator>) {
    let auth = HttpAuthLayer::builder()
        .oauth(oauth)
        .on_reject(|cx: RejectContext<'_>| {
            Response::builder()
                .header(CONTENT_TYPE, "application/json")
                .body(format!(r#"{{"status":{}}}"#, cx.status.as_u16()))
                .unwrap()
        })
        .build()
        .expect("a validator is configured");
    let _service = ServiceBuilder::new().layer(auth).service(service_fn(handler));
}

On hyper, adapt the tower service with hyper_util::service::TowerToHyperService. On an axum app, use axum::AuthLayer: the axum extractors answer 500 behind HttpAuthLayer, which they cannot tell ran.

§Any other stack: authenticate + refusal

Collect the candidate credentials yourself, call authenticate, and answer a refusal with what refusal returns — the status, and WWW-Authenticate set (not appended) to its value:

use oauth_resource_server::{Credential, OAuthValidator, authenticate, refusal};

/// On refusal: the status and the `WWW-Authenticate` value to send.
async fn check(
    bearer: Option<&str>,
    api_key: Option<&str>,
    static_token: Option<&str>,
    oauth: &OAuthValidator,
) -> Result<Credential, (u16, Option<String>)> {
    let candidates = bearer.into_iter().chain(api_key);
    authenticate(candidates, static_token, Some(oauth))
        .await
        .map_err(|rejection| {
            // Log `rejection` (an `Invalid` carries its kind and detail);
            // never send it.
            let r = refusal(&rejection, Some(oauth));
            (r.status, r.www_authenticate)
        })
}

With only a static token, refusal(&rejection, None) gives the same Bearer error="invalid_token" challenge the layers send (RFC 9110 §15.5.2 requires one on every 401); refusal_with_static_challenge picks another, or none (a challenge that is not a valid header value, such as one containing a line break, is replaced by the default). For a single token, call OAuthValidator::validate directly. Serve OAuthValidator::metadata() (a &serde_json::Value) as JSON, without authentication, on OAuthValidator::metadata_path() and, if you like, on the bare /.well-known/oauth-protected-resource (see Using with MCP for what that bare copy is and is not). A readiness probe is framework-free too: answer it from OAuthValidator::is_ready(), outside authentication, as in Readiness and liveness probes (never from refresh_now(), which fetches every time). TokenRejection is a std::error::Error whose Display is only its category (invalid token), so ? and {e} never expose the reason; the reason is in the variant (an InvalidToken: kind() for code and metrics, detail() for your log) and in Debug. examples/hyper.rs is a complete hyper 1.x server built this way, and examples/standalone_validator.rs walks through accepted and refused tokens against a fake authorization server.

§actix-web

actix-web runs on Tokio, so the same two functions work in an actix-web middleware. This sketch is not compiled by this crate’s CI (actix-web is not one of its dev-dependencies); it uses actix_web::middleware::from_fn (actix-web 4.9 or later):

use std::sync::Arc;

use actix_web::body::MessageBody;
use actix_web::dev::{ServiceRequest, ServiceResponse};
use actix_web::http::{StatusCode, header};
use actix_web::middleware::{Next, from_fn};
use actix_web::{App, Error, HttpMessage, HttpResponse, web};
use oauth_resource_server::{OAuthValidator, authenticate, refusal};

async fn require_auth(
    req: ServiceRequest,
    next: Next<impl MessageBody + 'static>,
) -> Result<ServiceResponse<impl MessageBody>, Error> {
    let oauth = req
        .app_data::<web::Data<OAuthValidator>>()
        .expect("the validator is registered as app data")
        .clone();
    let bearer = req
        .headers()
        .get(header::AUTHORIZATION)
        .and_then(|v| v.to_str().ok())
        .and_then(|v| v.split_once(' '))
        .filter(|(scheme, _)| scheme.eq_ignore_ascii_case("bearer"))
        .map(|(_, token)| token.trim().to_owned());
    match authenticate(bearer.as_deref(), None, Some(&oauth)).await {
        Ok(credential) => {
            req.extensions_mut().insert(credential);
            Ok(next.call(req).await?.map_into_left_body())
        }
        Err(rejection) => {
            let r = refusal(&rejection, Some(&oauth));
            let mut response = HttpResponse::build(StatusCode::from_u16(r.status).unwrap());
            if let Some(challenge) = r.www_authenticate {
                response.insert_header((header::WWW_AUTHENTICATE, challenge));
            }
            Ok(req.into_response(response.finish()).map_into_right_body())
        }
    }
}

// let oauth: Arc<OAuthValidator> = ...;
// App::new()
//     .app_data(web::Data::from(Arc::clone(&oauth)))
//     .service(web::scope("/api").wrap(from_fn(require_auth)) /* .route(...) */)

§Using with MCP

The crate is not specific to the Model Context Protocol, but it was extracted from an MCP server (mcp-md-wiki#308) and fits MCP’s authorization model:

  • Hosted clients need the challenge. claude.ai, Claude Desktop and the mobile apps cannot send a static bearer token or a custom header to a remote MCP server, so OAuth (an authorization-code flow) is the only way they can authenticate to a protected one. claude.ai has been observed not starting that flow at all when a 401 lacks resource_metadata in WWW-Authenticate. Claude Code tolerates its absence, which is why a missing header is easy to ship and hard to notice. This crate sends it on every 401 and 403.
  • Set resource to the MCP endpoint’s URL, e.g. https://mcp.example.com/mcp. The metadata is then served at /.well-known/oauth-protected-resource/mcp, where MCP clients look first, and at the bare /.well-known/oauth-protected-resource. A trailing slash is part of the path (.../mcp/ is described at .../oauth-protected-resource/mcp/, per RFC 9728 §3.1).
  • The bare-path copy is a compatibility deviation. The document served at the bare /.well-known/oauth-protected-resource still names https://mcp.example.com/mcp as its resource, and RFC 9728 §3.3 tells a client that derived the bare URL from https://mcp.example.com to discard a document whose resource differs. It is served because some clients fall back to the bare path and accept it; a compliant client uses the resource_metadata URL every challenge carries, so it costs nothing.
  • Use a resource-URL audience if you can. MCP requires a server to accept only tokens issued for it as the audience (RFC 8707 §2). With a client_id audience that holds only if the OAuth client is used for this one MCP server and no other; see Audience.
  • The scope is yours to choose. The crate has no default scope. Pick one, such as mcp:read, set it as required_scope, and advertise it (an omitted scopes_supported advertises the required scopes; if you list scopes_supported yourself, include it): claude.ai requests exactly what the metadata advertises, so an unadvertised required scope is a 403 on every call.
  • Mind scope hierarchies. MCP (2026-07-28, “Scope Challenge Handling”) says a server MUST account for a broader scope implying narrower ones. This crate’s check is an exact all-of match, so require only a scope every client token carries (if a write-only token is possible, mcp:read as the layer’s requirement would refuse it), and make hierarchy-aware decisions in the application with AuthorizedToken::has_scope.
  • Merge metadata_router outside the auth layer, on the same origin as the MCP endpoint.
  • Per-tool scopes need the request body. The auth layer sees HTTP requests, not the JSON-RPC method inside a POST to /mcp, so on its own any accepted token can call every tool. The mcp feature’s McpToolScopes reads the tool name and requires that tool’s scopes before the MCP server sees the request (below); a tool handler can also check scopes itself (Reading the token inside a tool handler).

§Per-tool scopes: McpToolScopes

With the mcp feature, oauth_resource_server::mcp::McpToolScopes is a tower layer for the MCP endpoint, placed behind the auth layer. It gives every request a default requirement and each named tool its own, reading the tool name from a tools/call in the POST body (the mcp module’s documentation compiles the same layering behind an HttpAuthLayer):

use axum::Router;
use oauth_resource_server::axum::AuthLayer;
use oauth_resource_server::mcp::McpToolScopes;

fn app(mcp_service: Router, auth: AuthLayer) -> Router {
    let tool_scopes = McpToolScopes::new()
        .default(["mcp:read"])
        .tool("write_document", ["mcp:write"]);
    Router::new()
        .nest_service("/mcp", mcp_service)
        .route_layer(tool_scopes)
        .route_layer(auth)
}

A token without the scopes is refused with 403 before the MCP server parses anything, with a challenge naming the layer’s scopes followed by that tool’s (scope="mcp:read mcp:write"), which is how a client knows to step up; a request with no credential gets the auth layer’s 401. What each request needs:

RequestRequires
an empty body (the event stream GET, DELETE, Content-Length: 0)the default
a body, under any method, with a tools/call for a listed toolthat tool’s scopes
tools/call for another tool, or any other methodthe default
a JSON-RPC batchevery scope any of its messages needs
a body that is not JSON, a tools/call with no readable params.name, or a message repeating method, params or params.nameevery scope in the configuration (default and all tools)
a body over the limit (body_limit, 1 MiB by default, 4 KiB to 64 MiB)refused with 413, unread past the limit
a body that fails while it is being read (the client disconnects, a transport error)refused with a bare 400, logged at warn (no challenge: nothing about the credential was wrong)

Anything it cannot classify with certainty gets the strictest set rather than the default, because the MCP server’s parser might read that body as a tool call; a caller holding every scope loses nothing, and the server answers a malformed body itself. A static token is refused wherever scopes are required, unless static_token_bypasses_scopes(). A served request reaches the MCP server with its body byte-identical (trailers are not passed on). The body type must be buildable from bytes (axum::body::Body, Full<Bytes>); nothing from the body is ever logged. The body is parsed in one streaming pass that builds no document, so memory stays at about the body itself however many messages a batch holds. A request with no credential (behind an optional() layer) gets the auth layer’s 401 when what it needs is not empty and is served when it needs nothing (an anonymous initialize, a public tool behind an empty default); only when every request needs a scope is it refused before its body is read. Every request’s body is read whatever its method or size hint (an empty one ends at once); whitespace alone is not empty, so it needs every scope.

Tool names are matched exactly — byte for byte on the JSON-decoded params.name, with no case folding, trimming or normalization — and a name with no entry gets the default. Register every tool under exactly the name your MCP server dispatches on, and make sure its dispatcher is exact too: one that would also run Write_Document or write_document as write_document lets a caller reach that tool under a spelling this layer treats as unconfigured, with only the default’s scopes.

Set a read timeout on the server. Reading the body has no timeout of its own, so a client that sends it slowly (slow-loris) holds the request open for as long as your server allows: configure a header/body read or request timeout (hyper’s, a tower_http::timeout layer, or your proxy’s), as for any endpoint that reads a body.

Scopes and limits read from configuration go through try_default, try_tool and try_body_limit, which return an McpScopesError naming the bad value; the plain forms panic and are for literals in code.

§Reading the token inside a tool handler

AuthLayer inserts AuthorizedToken (and Credential) into the HTTP request’s http::Extensions — the same place Reading the caller in a handler reads Credential from in a plain axum handler. A tool handler runs one layer further in, inside the MCP SDK’s JSON-RPC dispatch, so it has no axum extractor of its own; it needs the original http::request::Parts (extensions included), which is what carries the value here:

use http::request::Parts;
use oauth_resource_server::AuthorizedToken;

/// Read back what `AuthLayer` put on the request, given the `Parts` a tool
/// handler's own extractor hands it.
fn token_from_parts(parts: &Parts) -> Option<&AuthorizedToken> {
    parts.extensions.get::<AuthorizedToken>()
}

With the rmcp SDK, StreamableHttpService carries those Parts onto the request context a #[tool] handler receives (its own docs, “Accessing HTTP request data from tool handlers”): a handler taking rmcp::handler::server::tool::Extension<http::request::Parts> as a parameter gets exactly the Parts value token_from_parts above expects, and parts.extensions.get::<AuthorizedToken>() inside the handler reaches the token this crate validated. Extension<AuthorizedToken> on its own does not do this: rmcp’s Extension<T> extractor reads from its own request-scoped extension map, which holds the whole Parts value as one entry, not the individual values inside Parts.extensions — extract Parts first, as shown above, then read AuthorizedToken out of it.

A scope check there is token.require_scopes(&["mcp:write"]). It answers inside the protocol (a tool error), not with the 403 and challenge a client can re-authorize from; McpToolScopes does the latter.

§Provider guide

docs/providers.md has setup recipes and a checklist for any other server. Each recipe states how far it has been tested, and none claims more:

ProviderStatus
Authentikverified in production
Authelia 4.39verified in a sandbox, end to end
Kanidmtoken shape verified in a sandbox; not tested end to end
Keycloak, Okta, Microsoft Entra ID, Auth0, Ory Hydra, Logto, Casdoor, Rauthy, Dex, Zitadeldocumented-shape fixture, not live-tested

The Authentik, Authelia and Kanidm results were obtained with the mcp-md-wiki implementation this crate was extracted from, whose validation logic moved here unchanged; no token from those servers has been sent to this crate’s validator itself.

§Configuration changes need a restart

Nothing hot-reloads. A changed OAuthConfig, static token or credential source — or a changed fetch option (root certificate, proxy, timeout, initial key set), which is code rather than config but is read at the same moment — takes effect only when a new OAuthValidator and AuthLayer are built, which in practice means restarting the process. If your application reloads other settings live, treat all of these as restart-required and say so. Signing keys are the exception: they are refreshed in the background, so a key rotation at the authorization server needs no restart.

§Troubleshooting

The layer logs refusals under the target oauth_resource_server::axum: warn for a refused credential, and debug for an accepted OAuth token and, when OAuth is configured, for a request with no credential at all (every OAuth client’s first request looks like that). An accepted static token is logged at debug too (Static bearer auth accepted, with its label), and with only a static token configured a request with no credential is logged at warn like any other refusal. Every one of these lines also carries the stable auth.* fields described under Observability; filter on those rather than on the message. Enable debug for that target while setting up. A refusal looks like this:

WARN oauth_resource_server::axum: OAuth bearer auth rejected path=/v1/things reason=Invalid(InvalidToken { kind: WrongAudience, detail: "token rejected: InvalidAudience" }) auth.outcome="rejected" auth.mechanism="oauth" auth.reason="wrong_audience" auth.status=401

With only a static token configured, the line is Bearer auth rejected, with no reason. A per-route scope refusal (RequireScopes, McpToolScopes) is logged under oauth_resource_server::http_layer at info, as The credential lacks the scopes this route requires with the required and present scopes (and a Scoped extractor’s under oauth_resource_server::axum); a layer’s own require_scopes refusal is its usual warn line with reason=InsufficientScope. The table below is keyed on the detail text, which is for reading logs; in code, match InvalidToken::kind() instead. The validator and key-set messages come from the targets oauth_resource_server::validator and oauth_resource_server::jwks.

Reason or log lineCause and fix
token header lists critical extensions (crit), ...The authorization server marked the token with a JWS extension this crate cannot process (RFC 7515 §4.1.11). Configure it not to.
token nbf is not a NumericDate ...The token’s nbf is a string or out-of-range number. The authorization server is emitting a malformed token.
token is sender-constrained (cnf); ...The token is DPoP- or mTLS-bound, which this crate cannot verify. Configure the client or server to issue plain bearer tokens for this API.
token client "..." is not in ...allowed_client_ids, or token names no client (client_id or azp) ...The token was issued to a client you did not list, or names its client in another claim (Okta: cid; require that with required_claims instead).
token was issued ...s ago, over ...max_token_age_secs, or token has no iat and ...max_token_age_secs is setThe token is older than you allow, or carries no iat. Lower the authorization server’s token lifetime, or unset max_token_age_secs if it cannot emit iat.
token has no "..." claim, which ...required_claims requires, or token claim "..." does not match ...required_claimsA required_claims entry failed. Decode a real token and compare the claim’s name, type and value ("1" is not 1).
credential is not a JWT (...)The token is opaque, or it is a mistyped static token. Configure the authorization server to issue JWT access tokens (Authelia: access_token_signed_response_alg; Ory Hydra: strategies.access_token: jwt; Zitadel: the JWT token type).
token rejected: InvalidAudienceaud contains none of the configured audiences. Decode a real token and copy its aud into audience; see Audience.
token rejected: InvalidIssuer, or token iss is not a single string equal to ...issuer differs from the token’s iss, most often by a trailing slash. Copy it from the discovery document exactly.
token rejected: ExpiredSignature or ImmatureSignatureThe token has expired, or its nbf is in the future. Check both machines’ clocks; leeway_secs absorbs up to 300 seconds of skew.
token rejected: InvalidSignatureThe token was signed by a key other than the one with that kid in your JWKS: the wrong jwks_uri, or a token from a different server.
token rejected: Missing required claim: aud (or iss, exp)The token lacks a claim this crate requires. It is usually not an access token.
token algorithm HS256 is not in ...algorithmsThe server signs with a shared secret, which a resource server cannot verify. Give it an asymmetric signing key (Authentik: set a signing key on the provider). For an asymmetric algorithm, add it to algorithms.
token typ "JWT" is not accepted as an access token (...require_at_jwt is on)Your server does not emit at+jwt. Turn require_at_jwt off and rely on a required scope.
no RS256 key for kid "..." and the JWKS was refetched less than 60s agoThe kid is not in the key set, and the once-a-minute refetch was already used. A key rotation resolves itself within a minute; if it persists, the token comes from a different issuer or key set.
JWKS refresh failed: ...The key set could not be fetched or had no usable keys. The rest of the message says why: a DNS or TLS error, a non-success status, metadata issuer ... does not match, or the JWK Set contained no usable signature keys for ...algorithms. A certificate error against a server with a private CA means the default rustls-tls feature, which trusts only its built-in Mozilla roots: add the CA with add_root_certificate_pem, or see Choosing a TLS backend.
403, with OAuth token is valid but lacks the required scope at infoThe line lists required, present and scope_claims. present=[] usually means the scopes are in a claim missing from scope_claims, or the server did not grant the scope (Authentik needs a scope mapping, Kanidm a scope map).
Startup warn: required scope(s) ... not in ...scopes_supportedClients request what is advertised and will get 403. Add the scope to scopes_supported. (An empty scopes_supported never logs this: the 401 challenge then names the required scopes.)
Startup error: no required scope is configured ...With no scope and no require_at_jwt, ID tokens would be accepted too. Set a required scope (or require_at_jwt, or allow_unscoped_tokens if that is really intended, which then logs a startup warn: no required scope configured ... ANY token this issuer signs ...).
Startup error: ... uses plain http on a non-loopback hostUse https, or set allow_insecure_http for an address on a trusted network (which then logs the same line as a startup warn).
JWKS refresh failed: ... jwks_uri "http://..." uses plain http on a non-loopback host or redirect to plain http on a non-loopback host ... refusedA loopback http issuer’s metadata, or a redirect during a key fetch, points at a cleartext non-loopback URL. Serve it over https, or set allow_insecure_http if that network is trusted (each use is then logged at warn).
Startup error: ... is not a valid scopeA scope with a space, ", \ or non-printable character. Scopes are RFC 6749 scope-tokens.
Startup warn: OAuth: could not load the authorization server's signing keysThe first background key load failed (unreachable issuer, discovery mismatch, TLS). Requests fail closed until a later attempt succeeds; with no key held the task retries after 5 s, backing off to 5 minutes (retry_in_secs in the log line). key_set_status() shows the last error; see Readiness and liveness probes.
warn: JWKS key declares no alg, so it may verify any of ...A key in the set has no alg. Narrow algorithms to the algorithm your server signs with.
A client never starts the login flowThe 401 lacks WWW-Authenticate, or the metadata is unreachable. Check that metadata_router is merged outside the auth layer, that no proxy strips the header, and that resource is the URL clients actually use.
The metadata document is 404OAuth is off (metadata_router(None) answers 404), or the path does not match the path of resource. OAuthValidator::metadata_path() returns the path served.
A static token that used to work is refusedWith OAuth on, accept_static_bearer: false makes static_token_policy return StaticIgnored.
AuthLayerError::NoCredential or NoAuthConfiguredNeither a static token nor OAuth is configured. Configure one, or opt out explicitly with allow_unauthenticated.

§Testing your integration

Enable the testing feature from [dev-dependencies] only (its signing keys are public, so anything that trusts them trusts everyone):

[dev-dependencies]
oauth-resource-server = { version = "0.2", features = ["testing"] }

That keeps it out of a production build under Cargo’s feature resolver version 2, the default since edition 2021. A package on edition 2018 or earlier without resolver = "2" uses version 1, which unifies dev-dependency features into cargo build too, so testing (its module and its public keys, though no validator path changes) would be compiled into the binary: set resolver = "2" there.

testing::TestAuthority is a fake authorization server on a loopback port. It serves discovery (OpenID Connect and RFC 8414) and a JWKS, and hands you a config and tokens that agree with it, with neutral defaults: resource https://api.example.test/, a distinct audience https://api.example.test/audience, required scope api:read, settings named oauth.*. Each thing a test wants wrong is one builder call.

use std::sync::Arc;

use axum::{Router, body::Body, http::{Request, StatusCode}, routing::get};
use oauth_resource_server::axum::AuthLayer;
use oauth_resource_server::testing::TestAuthority;
use oauth_resource_server::{AuthorizedToken, OAuthValidator};
use tower::ServiceExt; // for `oneshot`

#[tokio::main(flavor = "current_thread")]
async fn main() {
    let authority = TestAuthority::start().await;
    // Adjust anything before the config is resolved; it panics with the
    // `ConfigError` text if the result is invalid.
    let config = authority.config(|c| c.require_at_jwt = true);
    let validator = Arc::new(OAuthValidator::new(&config).unwrap());

    let app = Router::new()
        .route(
            "/whoami",
            get(|token: AuthorizedToken| async move { token.subject.unwrap_or_default() }),
        )
        .route_layer(AuthLayer::builder().oauth(validator).build().unwrap());
    let request = |bearer: String| {
        Request::builder()
            .uri("/whoami")
            .header("authorization", format!("Bearer {bearer}"))
            .body(Body::empty())
            .unwrap()
    };

    let ok = app.clone().oneshot(request(authority.token().subject("ada").sign())).await.unwrap();
    assert_eq!(ok.status(), StatusCode::OK);

    let expired = app.clone().oneshot(request(authority.token().expired().sign())).await.unwrap();
    assert_eq!(expired.status(), StatusCode::UNAUTHORIZED);

    let no_scope = app.oneshot(request(authority.token().scopes(["other:scope"]).sign())).await.unwrap();
    assert_eq!(no_scope.status(), StatusCode::FORBIDDEN);
}

(This block is fenced text because the README is compiled without the testing feature; its body is identical to the doctest in the testing module docs, which is compiled and run. Use #[tokio::test] instead of #[tokio::main] in a real test.) The example needs tower with the util feature (for ServiceExt::oneshot) as a dev-dependency.

The token builder covers what an access-token test needs to get wrong: .subject(), .scopes(), .audience()/.audiences(), .issuer(), .expires_in(secs), .expired(), .not_before_in(secs), .issued_ago(secs), .typ(), .without_typ(), .alg() (RS*, PS*, ES256, EdDSA, each signed with the matching published throwaway key), .kid(), .claim() (also the way to set an absolute or malformed exp/nbf/iat), .without_claim() and .sign(). Key rotation is authority.rotate_key() (publishes the new key next to the old) followed, if you want the old key gone, by authority.withdraw_old_key() (its tokens then fail once the validator refreshes). authority.jwks_fetches() and authority.discovery_fetches() count what the authority served, and authority.set_response_delay(..) simulates a slow one. For a handler test that needs no validation at all, build the verified value directly with AuthorizedToken::new(..) and with_claims(..).

testing follows semver like the rest of the crate: a breaking change to it ships in a new 0.x minor, so your test suite is not broken by a patch or additive release.

§Examples

Every example runs locally without network access. The ones that validate tokens start the testing feature’s fake authorization server on a loopback port and mint tokens with its throwaway keys.

cargo run --example axum_basic --features axum,serde,testing
cargo run --example multiple_sources --features axum,testing
cargo run --example standalone_validator --features testing
cargo run --example env_config --features env,axum
cargo run --example hyper --features testing
ExampleWhat it shows
axum_basicYAML config, the middleware and the metadata router, with requests sent through the router and each response printed.
multiple_sourcesAuthorization: Bearer plus an X-Api-Key header, a static key in dual mode, and JSON rejection bodies.
standalone_validatorThe validator without axum: discovery, validate, authenticate and the challenge headers.
env_configLoading everything from MYAPP_OAUTH_* variables, an application default for the required scope, and static_token_policy.
hyperA plain hyper 1.x server with no framework: authenticate, refusal for the 401/403 and challenge, and the metadata document, driven by hyper’s client.

§MSRV and semver policy

The minimum supported Rust version is 1.89 (edition 2024). It is declared as rust-version and checked in CI.

The crate follows semantic versioning as Cargo applies it before 1.0: a release that breaks the public API increments the minor version (0.1 to 0.2). A patch release (0.1.0 to 0.1.1) preserves behavior, with one exception: a fix for a vulnerability, where a forged, expired, wrongly-audienced or otherwise out-of-policy token was being accepted, ships as a patch release even though it refuses tokens that were accepted before. It comes with a CHANGELOG.md entry and a security advisory. Holding such a fix for the next minor release would leave everyone on the usual "0.2" requirement unprotected. If you pin an exact version (=0.2.x), expect a patch to narrow what is accepted when it fixes a vulnerability.

A new minor release is required for:

  • Raising the MSRV.
  • A major-version bump of a dependency whose types appear in the public API: serde and serde_json, always (AuthorizedToken::claims_as is bounded by DeserializeOwned, claims() and metadata() return serde_json types); http, tower-layer and tower-service under the tower feature (CredentialSource holds an http::HeaderName, static_challenge takes an http::HeaderValue, and both layers implement tower_layer::Layer and their services tower_service::Service over http::Request); http-body and bytes under the mcp feature (McpToolScopesService’s request body is bounded by http_body::Body + From<bytes::Bytes>); and axum under the axum feature (metadata_router returns an axum::Router<S>, require_auth takes axum’s State, Request and Next). The JWT library is not part of the API (Algorithm is this crate’s own type), so replacing it is not a breaking change.
  • A new configuration field. Types that may grow are #[non_exhaustive], but OAuthConfig deliberately is not, so it can be built with struct-literal syntax.

Log field, span and metric names are a stable API. The auth.* log fields, the oauth_rs.* span names and fields, the oauth_rs_* metric names and labels, and every value Observability lists are covered by semver: renaming one, or moving an outcome to another value, is a breaking change; adding a value is not.

Message text is not a stable API. The wording of ConfigError::problems (and the Display built from it), of rejection reasons, and of log lines may change in any release, a patch included. The troubleshooting table above is for reading logs; do not string-match these messages in code. Match on the types instead (TokenRejection, ValidatorError, AuthLayerError, and so on): for a refused token, on InvalidToken::kind() (InvalidTokenKind, with as_str() labels for metrics), and for configuration problems, on ConfigProblem::kind() (ProblemKind) and keys(). These are the durable alternative to matching the sentence. The kind a given refusal or problem carries, and each kind’s as_str() label, are part of the contract (reclassifying one is a breaking change; adding a variant is not), while the message text (InvalidToken::detail(), ConfigProblem::message()) is not.

§License

MIT. See LICENSE.

§API map

ItemRole
OAuthConfig, OAuthConfig::resolveThe unvalidated settings, and their all-or-nothing validation into a ResolvedOAuthConfig or a ConfigError. KeyNaming decides how problems name settings.
OAuthValidatorValidates one token (OAuthValidator::validate), renders the challenges and the metadata document, and keeps the signing keys fresh (OAuthValidator::spawn_background_refresh).
OAuthValidator::builder, OAuthValidatorBuilderHow the validator fetches its keys: extra TLS root certificates, a proxy, the fetch timeout, a key set to start from.
OAuthValidator::key_set_status, KeySetStatus, RefreshErrorA passive, no-I/O view of the signing keys held, for readiness probes and status pages (OAuthValidator::is_ready).
AuthorizedToken, TokenRejectionThe two outcomes of a validation.
authenticate, CredentialFramework-free checking of several candidate credentials against a static token and OAuth.
authenticate_with_static_tokens, StaticTokens, StaticTokenMatch, StaticTokensErrorThe same check against several labeled static tokens (zero-downtime key rotation, one key per client), reporting which one matched.
refusal, refusal_with_static_challenge, Refusal, DEFAULT_STATIC_CHALLENGEFramework-free mapping of a TokenRejection to its status (401/403) and WWW-Authenticate challenge — the same decision both layers make.
AuthorizedToken::require_scopes, MissingScopes, refusal_for_scopes, OAuthValidator::insufficient_scope_challenge_forPer-route and per-operation scopes on top of the validator’s: the all-of check, and the 403 whose challenge names the scopes that request needs.
Algorithm, parse_algorithm, AlgorithmErrorThe JWS algorithms a config may allow (never HMAC or none).
static_token_policy, StaticTokenDecisionThe startup decision about a static API key alongside OAuth.
axum::AuthLayer, axum::require_auth, axum::metadata_routerThe axum integration (feature axum), including extractors for Credential, AuthorizedToken and StaticTokenMatch, and the per-handler scope extractor axum::Scoped.
http_layer::HttpAuthLayer, http_layer::HttpAuthLayerBuilder, http_layer::RequireScopesA tower layer for any http::Request<B> service, whatever its body types (feature tower, implied by axum), and the per-route scope layer both layers share.
env::oauth_config_from_env, env::secret_from_env, env::static_tokens_from_envConfiguration from environment variables (feature env), including a current and a next static key for rotation.
mcp::McpToolScopesPer-tool scopes for an MCP server’s JSON-RPC endpoint (feature mcp).
observabilityThe metric names the metrics feature reports through the metrics facade, and their descriptions (feature metrics).
testingFixtures for your tests (feature testing).

Re-exports§

pub use config::ConfigError;
pub use config::ConfigProblem;
pub use config::DEFAULT_LEEWAY_SECS;
pub use config::DEFAULT_PRINCIPAL_CLAIMS;
pub use config::DEFAULT_SCOPE_CLAIMS;
pub use config::KeyNaming;
pub use config::KeyNamingBuf;
pub use config::MAX_LEEWAY_SECS;
pub use config::MAX_TOKEN_AGE_SECS;
pub use config::OAuthConfig;
pub use config::ProblemKind;
pub use config::ResolvedOAuthConfig;

Modules§

axumaxum
axum integration: the authentication layer (AuthLayer, a tower::Layer, also usable as state for the require_auth middleware function) and the RFC 9728 metadata routes (metadata_router).
config
Configuration: the unvalidated input shape (OAuthConfig), its validation (OAuthConfig::resolve), and the validated shape the validator is built from (ResolvedOAuthConfig).
envenv
Loading secrets and the OAuth config from environment variables (with VAR_FILE support).
http_layertower
A tower authentication layer for any HTTP stack built on the http crate’s types — hyper, tonic, or a tower service of your own — whatever its body types: HttpAuthLayer (built with HttpAuthLayerBuilder) wraps a Service<http::Request<ReqBody>, Response = http::Response<ResBody>> for any ReqBody and any ResBody.
mcpmcp
An integration helper for Model Context Protocol servers (feature mcp): per-tool scope requirements, enforced before the MCP server sees the request. The rest of this crate is protocol-agnostic; this module is the one place that knows what a JSON-RPC tools/call looks like, and it depends on nothing MCP-specific (no MCP SDK — serde_json only).
observabilitymetrics
The metrics this crate emits through the metrics facade (feature metrics).
testingtesting
Test-only fixtures for testing code that sits behind this crate: a fake authorization server (TestAuthority), a fluent token builder (TokenBuilder), and the lower-level throwaway keys, JWK builders, minting functions and fake HTTP server they are built from.

Structs§

AuthorizedToken
A successfully validated access token. The axum middleware (feature axum) inserts it into request extensions, so handlers can read who called and with which scopes.
InvalidToken
Why a credential was refused as TokenRejection::Invalid: a stable, matchable kind and a log-only detail.
KeySetStatus
A point-in-time view of the signing keys an crate::OAuthValidator holds, from crate::OAuthValidator::key_set_status.
MissingScopes
A token that is valid but lacks scopes a route or operation requires; see AuthorizedToken::require_scopes. It answers with 403 insufficient_scope: convert it with ? (or From) into TokenRejection::InsufficientScope, and build the response with crate::refusal_for_scopes so the challenge names what was required.
NoAuthConfigured
Neither a static token nor OAuth is configured, and unauthenticated access was not explicitly allowed. The application should refuse to start, telling the operator in its own terms how to configure one of the two (or how to opt out explicitly).
OAuthValidator
Validates bearer credentials as JWT access tokens (RFC 9068) for one resource.
OAuthValidatorBuilder
Builds an OAuthValidator with options for its metadata and JWKS fetches; get one from OAuthValidator::builder.
RefreshError
A failed key refresh: discovery, fetch, or a key set with nothing usable in it. The keys held before the attempt are kept.
Refusal
The HTTP refusal a TokenRejection calls for; see refusal.
StaticTokenMatch
Which StaticTokens entry accepted a request: returned by authenticate_with_static_tokens next to Credential::StaticToken, and inserted into request extensions by the layers (features axum and tower) whenever they insert Credential::StaticToken — with the axum feature it is also an extractor. It carries the entry’s label only; nothing in it exposes the secret.
StaticTokens
A set of static API keys, each with an optional label: several keys accepted at once, for rotating a key with no downtime (the old and the new one both accepted until every client has moved) or for one key per client.

Enums§

Algorithm
A JWS signature algorithm this crate can verify an access token with.
AlgorithmError
Why parse_algorithm refused a name.
Credential
Which mechanism accepted a request.
InvalidTokenKind
Which check refused a token — see InvalidToken::kind.
RefreshErrorKind
The stage at which a key refresh failed — see RefreshError::kind.
StaticTokenDecision
The outcome of static_token_policy.
StaticTokensError
Why StaticTokens::with (or StaticTokens::single) refused an entry. Each variant names the entry by its 0-based position in the set, and never includes a secret.
TokenRejection
Why a bearer credential was refused, and — crucially — with which HTTP status.
ValidatorError
Why an OAuthValidator could not be built.

Constants§

DEFAULT_ALGORITHMS
Default crate::OAuthConfig::algorithms: every asymmetric JWS algorithm this crate can verify. Deliberately wide — which algorithm a token may use is ALSO constrained by the key it names, so accepting ES256 here cannot make an RSA key verify an ES256 signature. HS256/384/512 and none are not merely absent: parse_algorithm refuses them, so no config can turn them on. ES512 is absent because the underlying ring cannot verify P-521.
DEFAULT_FETCH_TIMEOUT
Ceiling on a single metadata or JWKS fetch, unless crate::OAuthValidatorBuilder::fetch_timeout sets another. Bounds how long a refresh holds refresh_lock, and therefore how long a stalled IdP can stall validation of a token whose key is not already cached.
DEFAULT_STATIC_CHALLENGE
The WWW-Authenticate value a refusal carries when no OAuth validator is configured, unless the application chose another (the layers’ static_challenge, or refusal_with_static_challenge).
MAX_FETCH_TIMEOUT
The longest timeout crate::OAuthValidatorBuilder::fetch_timeout accepts. The timeout bounds how long a refresh holds the refresh lock, and every request whose key is not cached waits behind it — a discovery pass can chain three fetches — so a minute (the unknown-kid refetch interval) is the ceiling.
MIN_FETCH_TIMEOUT
The shortest timeout crate::OAuthValidatorBuilder::fetch_timeout accepts. Zero would fail every fetch; under a second a TLS handshake to a distant authorization server fails intermittently.
PROTECTED_RESOURCE_METADATA_PREFIX
Path segment RFC 9728 §3 splices between a resource’s authority and its path to form the metadata URL. The bare prefix is also the route that answers for a resource whose URL has no path.

Functions§

authenticate
Check every candidate credential against every configured mechanism, and accept if ANY candidate satisfies ANY mechanism.
authenticate_with_static_tokens
authenticate against a set of static tokens, reporting which one matched: the same candidate handling, order and refusal precedence, over every entry of static_tokens instead of one token.
parse_algorithm
Parse one crate::OAuthConfig::algorithms entry, refusing everything that must never be accepted with the reason spelled out (the error lands in a startup failure). crate::OAuthConfig::resolve calls it for every entry; it is public for applications that validate an algorithm list themselves.
refusal
The status and WWW-Authenticate challenge to answer a refused request with — exactly what the axum and tower layers send.
refusal_for_scopes
refusal for a request that needed scopes on top of the validator’s own required scopes — a per-route or per-operation requirement checked with crate::AuthorizedToken::require_scopes. Everything but the 403 challenge is exactly refusal’s: the same status for every rejection, the same 401 challenge, DEFAULT_STATIC_CHALLENGE on a 401 without OAuth. Without OAuth a 403 carries Bearer error="insufficient_scope" (RFC 6750 §3.1) rather than the static 401 challenge refusal gives.
refusal_with_static_challenge
refusal, with the challenge to send when oauth is None chosen by the caller — the counterpart of the layers’ static_challenge setting. static_challenge is ignored when oauth is Some: with OAuth, every refusal carries the validator’s challenge.
static_token_policy
Decide which static token the auth layer holds.