Skip to content

Resource Server

Authkestra isn’t just for logging users in; you can also use it to build an OAuth2 Resource Server to protect your internal APIs using tokens (like JWTs) issued by an external provider (like Google or Auth0).

According to RFC 6749, a Resource Server is the server hosting protected resources, capable of accepting and responding to protected resource requests using access tokens.

To securely validate an external JWT, the Resource Server verifies the cryptographic signature using the external provider’s public keys (JWKS, defined in RFC 7517).

Like the OP Server, the Resource Server is an advanced component and is not included by default. You must explicitly include the authkestra-resource crate and enable the resource feature on your chosen web adapter.

[dependencies]
authkestra-resource = "0.9"
authkestra-axum = { version = "0.9", features = ["resource"] }
# Or if using Actix:
# authkestra-actix = { version = "0.9", features = ["resource"] }

Outbound HTTP requests for JWKS discovery require a TLS backend. Authkestra enables rustls-aws-lc-rs by default. If your project uses a different TLS implementation (such as ring), you can select rustls-no-provider and install your own rustls::CryptoProvider:

[dependencies]
authkestra-resource = { version = "0.9", default-features = false, features = ["rustls-no-provider"] }

Authkestra provides the authkestra-resource crate, which features a Guard and a JwtStrategy that automatically caches and refreshes external JWKS.

First, set up ValidationConfig and point it to the issuer’s JWKS URL. The strategy automatically fetches and caches public keys in the background.

use authkestra_resource::{jwt::JwtStrategy, jwt::ValidationConfig, Guard};
let issuer = "https://accounts.google.com".to_string();
let validation_config = ValidationConfig::builder()
.jwks_url(format!("{}/.well-known/jwks.json", issuer))
.issuer(issuer)
.build();
// UserIdentity is your custom struct that implements Deserialize
let jwt_strategy = JwtStrategy::<UserIdentity>::new(validation_config);

The ValidationConfig::builder() supports advanced validation rules and cryptographic key types:

  • Supported Signing Algorithms: Supports RS256, RS384, RS512, ES256, ES384, and EdDSA (Ed25519 OKP keys).
  • Multi-Audience Support: To accept tokens minted for any of multiple distinct client IDs, chain .audiences(["client_a", "client_b"]).
  • Strict Key IDs (kid): To strictly reject malformed tokens that lack a kid header instead of falling back to the first JWKS key, chain .require_kid(true).
  • Clock-skew tolerance: exp and nbf are checked with a 60-second allowance, so a token is accepted for about a minute past its expiry. That absorbs disagreement between the issuer’s clock and yours. Chain .leeway(0) where both share a clock, or in tests that assert a token is rejected once it expires — with the default, such a test passes when you expect it to fail. The value is authkestra_engine::token::DEFAULT_LEEWAY_SECS, the same constant TokenManager uses.
let advanced_config = ValidationConfig::builder()
.jwks_url("https://example.com/jwks")
.audiences(["admin_portal", "mobile_app"])
.require_kid(true) // Reject tokens without a 'kid' header
.build();

One verifier can accept tokens from several issuers, each with its own JWKS endpoint. The token’s iss claim selects which JWKS verifies it:

let config = ValidationConfig::builder()
.trusted_issuer("https://tenant-a.example", "https://tenant-a.example/.well-known/jwks.json")
.trusted_issuer("https://tenant-b.example", "https://tenant-b.example/.well-known/jwks.json")
.audience("my-app")
.build();

Once any trust-map entry is present the verifier is in multi-issuer mode: an iss outside the map is rejected outright and a token with no iss at all is rejected too. There is deliberately no fallback endpoint — a default JWKS for unknown issuers is issuer confusion. For issuers only known at runtime, implement JwksResolver and use JwtStrategy::with_resolver(config, resolver); the same rule applies, your implementation must reject issuers it does not recognise.

Two optional checks bind an access token to the client that was issued it, so a stolen token is not usable on its own. Both are off by default, and both are no-ops for a token that carries no binding claim at all:

  • .require_cert_binding(true) — RFC 8705 mutual-TLS binding: a token with cnf.x5t#S256 is only accepted if the request’s client certificate matches.
  • .require_dpop(true) — RFC 9449 DPoP: a token with cnf.jkt is only accepted if the request carries a DPoP proof header for the same key, bound (ath) to this token and not seen before. Optionally add .dpop_resource_origin("https://api.example.com") to also check the proof’s htu against this server’s own origin.
use std::sync::Arc;
let config = ValidationConfig::builder()
.jwks_url("https://idp.example/jwks")
.require_dpop(true)
.dpop_resource_origin("https://api.example.com") // scheme + host only
.build();
let strategy = JwtStrategy::<UserIdentity>::new(config)
// Required: the default `NoDpopReplayStore` refuses every DPoP-bound token.
.with_dpop_replay_store(Arc::new(my_replay_store));

JwksCache fetches with a reqwest::Client that trusts only the platform certificate store, which makes a JWKS published under a private CA unreachable — the common shape being a resource server validating tokens against an issuer inside its own cluster. Supply your own client instead of replacing the whole process’s trust store via SSL_CERT_FILE:

use authkestra_resource::jwt::JwksCache;
use std::time::Duration;
let cache = JwksCache::new("https://idp.internal/jwks".to_string(), Duration::from_secs(300))
.with_client(my_client_with_private_ca) // reqwest::ClientBuilder::add_root_certificate(...)
.require_kid(true);

The Guard builder allows you to chain multiple strategies together. Under the default policy — AuthPolicy::FirstSuccess — a request passes if any of the chained strategies successfully validates it (e.g., accepting an OIDC JWT or an API key). AuthPolicy::AllSuccess and AuthPolicy::FailFast are the alternatives; pass one to .policy(...) to override.

ApiKeyStrategy below is illustrative, not something Authkestra ships. Any type implementing authkestra_engine::strategy::AuthenticationStrategy<I> slots into the chain — the trait is a single async fn authenticate(&self, parts: &Parts) -> Result<Option<I>, AuthError>, where Ok(None) means “no credentials of my kind here, try the next strategy” and Err fails the whole chain.

use authkestra_axum::AxumState;
use std::sync::Arc;
// We chain the JWT strategy alongside an API Key strategy!
let guard = Guard::builder()
.strategy(jwt_strategy)
.strategy(ApiKeyStrategy::new(...))
.build();
#[derive(Clone, AxumState)]
struct AppState {
#[authkestra(store)]
guard: Arc<Guard<UserIdentity>>,
}

Now use the Auth extractor in your handlers. If the incoming request satisfies any strategy in your Guard, it is deserialized into your UserIdentity struct:

use authkestra_axum::Auth;
use axum::response::{IntoResponse, Json};
async fn protected(Auth(user): Auth<UserIdentity>) -> impl IntoResponse {
Json(serde_json::json!({
"message": "Access granted via Resource Server Strategy!",
"user": user,
}))
}