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).
What is a Resource Server?
Section titled “What is a Resource Server?”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).
Prerequisites
Section titled “Prerequisites”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"] }TLS Backend Selection
Section titled “TLS Backend Selection”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"] }Building with Authkestra Guard
Section titled “Building with Authkestra Guard”Authkestra provides the authkestra-resource crate, which features a Guard and a JwtStrategy that automatically caches and refreshes external JWKS.
1. Configure the Strategy
Section titled “1. Configure the Strategy”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 Deserializelet jwt_strategy = JwtStrategy::<UserIdentity>::new(validation_config);Advanced Cryptography & JWT Validation
Section titled “Advanced Cryptography & JWT Validation”The ValidationConfig::builder() supports advanced validation rules and cryptographic key types:
- Supported Signing Algorithms: Supports
RS256,RS384,RS512,ES256,ES384, andEdDSA(Ed25519OKP 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 akidheader instead of falling back to the first JWKS key, chain.require_kid(true). - Clock-skew tolerance:
expandnbfare 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 isauthkestra_engine::token::DEFAULT_LEEWAY_SECS, the same constantTokenManageruses.
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();Accepting several issuers
Section titled “Accepting several issuers”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.
Sender-constrained tokens (DPoP and mTLS)
Section titled “Sender-constrained tokens (DPoP and mTLS)”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 withcnf.x5t#S256is only accepted if the request’s client certificate matches..require_dpop(true)— RFC 9449 DPoP: a token withcnf.jktis only accepted if the request carries aDPoPproof 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’shtuagainst 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));JWKS behind a private CA
Section titled “JWKS behind a private CA”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);2. Flexible Strategy Chaining
Section titled “2. Flexible Strategy Chaining”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>>,}3. Protect Endpoints
Section titled “3. Protect Endpoints”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, }))}use authkestra_actix::ActixState;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, ActixState)]struct AppState { #[authkestra(store)] guard: Arc<Guard<UserIdentity>>,}3. Protect Endpoints
Section titled “3. Protect Endpoints”Now use the Auth extractor in your handlers:
use authkestra_actix::Auth;use actix_web::{post, Responder, web::Json};
#[post("/protected")]async fn protected(user: Auth<UserIdentity>) -> impl Responder { let Auth(user) = user; Json(serde_json::json!({ "message": "Access granted via Resource Server Strategy!", "user": user, }))}