Volga
Home
Agent Skill
API Docs
GitHub
  • English
  • Русский
Home
Agent Skill
API Docs
GitHub
  • English
  • Русский
  • Home
  • Getting Started

    • Quick Start
    • Handlers
    • Route Parameters
    • Query Parameters
    • Route Groups
  • Requests & Responses

    • Headers
    • Handling JSON
    • Handling Form Data
    • Working with Files
    • Multipart Responses
    • Raw Request Body
    • Cookies
    • Validating Requests
  • Middleware & Infrastructure

    • Basic Middleware
    • Custom Middleware
    • Parameterized Middleware
    • Response Compression
    • Request Decompression
    • CORS (Cross-Origin Resource Sharing)
    • Static Files
    • Rate Limiting
    • Configuration Files
    • OpenAPI
  • Security & Access

    • Authentication and Authorization
    • OAuth 2.1 & OpenID Connect
    • OAuth 2.1 Client
    • Machine-to-Machine Grants
    • DPoP — Sender-Constrained Tokens
  • Reliability & Observability

    • Global Error Handling
    • Tracing & Logging
    • Request cancellation
    • Graceful Shutdown
  • Protocols & Realtime

    • HTTP Versions and Methods
    • HTTPS
    • WebSockets
    • Server-Sent Events (SSE)
  • Advanced Patterns

    • Dependency Injection
    • Custom Handling of HEAD, OPTIONS, and TRACE Methods
  • Agent Skill

DPoP — Sender-Constrained Tokens

A bearer token is a password: whoever holds it may use it. DPoP (RFC 9449) binds a token to a key the client holds, and every request carries a freshly signed proof of possession — so a token stolen from a log, a proxy or a compromised store is worth nothing without the key.

Available since v0.9.8 behind the dpop feature of volga-oauth-client:

[dependencies]
volga-oauth-client = { version = "...", features = ["dpop"] }

Info

The feature is off by default because signing is the only part of the crate that needs a JWS backend. It is independent of private-key-jwt: a client credential says who asked for a token, a DPoP proof says who holds it, and one request may carry both.

The Key

Dpop is the key plus the nonce state of the servers it talks to. generate() mints a throwaway ES256 key — the algorithm every DPoP implementation supports:

use volga_oauth_client::{ClientError, Dpop, OAuthClient};

fn build() -> Result<(), ClientError> {
    let dpop = Dpop::generate()?;

    let client = OAuthClient::new("my-client")
        .with_secret("s3cret")
        .with_dpop(dpop.clone());

    // the same key protects the resource requests made with its tokens
    let jkt = dpop.thumbprint();
    Ok(())
}

The usual lifetime is one key per session, not one per process — losing it costs nothing beyond the tokens bound to it, which cannot be used without it anyway. generate_with also does ES384 and EdDSA; RSA keys are far too slow to generate per session and have to be loaded instead.

For a key that outlives the process — one whose thumbprint a resource has already been told about — use from_pem or from_pem_file, which take the public half explicitly and verify the two halves against each other with one signature at construction, so a mismatched pair is refused there rather than failing remotely on every request it would ever sign.

Cloning a Dpop shares the key and the nonces, so the client and the code making resource requests with its tokens stay in step.

Obtaining Bound Tokens

with_dpop puts a proof on every token request, whichever grant sends it — Authorization Code, client credentials, JWT bearer or token exchange. Everything else is handled for you:

  • the algorithm is checked against dpop_signing_alg_values_supported before the request leaves;
  • the authorization request names the key in dpop_jkt (RFC 9449 §10), binding the code to it, so a stolen code cannot be redeemed by anyone else;
  • a use_dpop_nonce refusal (§8.2) is answered by repeating the request exactly once with the nonce that refusal demanded;
  • the tokens come back as token_type: DPoP.
use volga_oauth_client::{AuthorizationServerMetadata, ClientError, Dpop, OAuthClient};

async fn tokens(metadata: &AuthorizationServerMetadata) -> Result<(), ClientError> {
    let dpop = Dpop::generate()?;

    let tokens = OAuthClient::new("my-service")
        .with_secret("s3cret")
        .with_dpop(dpop.clone())
        .client_credentials(metadata)
        .with_scopes(["inventory:read"])
        .send()
        .await?;

    assert!(tokens.is_dpop());
    assert_eq!(tokens.dpop_jkt.as_deref(), Some(dpop.thumbprint()));
    Ok(())
}

Warning

A token that does not come back as DPoP is refused rather than handed to the caller and the token store as an unbound credential — a server without DPoP support simply ignores the proof, and silently accepting a bearer token there would give up the binding the key exists for.

TokenSet::is_dpop and TokenSet::dpop_jkt report the binding recorded by the client that obtained the token. A TokenStore outlives a process while a generated key does not, so a stored entry is checked against the key in hand before it is served: an entry bound to a key this client cannot prove possession of is dead weight however unexpired it looks. That is not an error — it is a stale cache, so the entry is evicted and a token that fits is obtained instead.

Protecting Resource Requests

Requests to the resource stay yours to make: this crate mints proofs and owns the nonce state, it does not become an HTTP client for you. authorize fills in both headers — the Authorization: DPoP <token> credential and the DPoP proof covering it:

use http::{HeaderMap, Method};
use volga_oauth_client::{ClientError, Dpop, TokenSet};

fn protect(dpop: &Dpop, url: &str, tokens: &TokenSet) -> Result<(), ClientError> {
    let mut headers = HeaderMap::new();
    let sent = dpop.authorize(&mut headers, &Method::GET, url, tokens)?;

    // ...send the request with these headers
    Ok(())
}

The proof carries typ: dpop+jwt, the public key by value in the jwk header (never by kid, unlike a client assertion), the htm / htu / iat / jti claims, and ath — the hash of the access token — on every request that presents one. htu drops the query and fragment.

Warning

authorize refuses a token this key cannot present — a bearer token, or one whose recorded binding names a different key — before the request, rather than letting the resource refuse it on every one.

Two lower-level pieces are available when authorize is not the right shape:

  • proof builds a proof by hand — with_access_token, with_nonce, then sign;
  • thumbprint is the jkt (RFC 7638) an authorization server binds a token to — the value to hand to a resource that pins keys out of band.

Nonces

A server may demand that proofs carry a nonce of its choosing. The token endpoint's round is handled internally; the resource's round is yours, because you send those requests:

use http::{HeaderMap, Method};
use volga_oauth_client::{ClientError, Dpop, TokenSet};

fn with_retry(dpop: &Dpop, url: &str, tokens: &TokenSet) -> Result<(), ClientError> {
    let mut headers = HeaderMap::new();
    let sent = dpop.authorize(&mut headers, &Method::GET, url, tokens)?;

    // ...send the request; then, given a `use_dpop_nonce` refusal:
    let response_headers = HeaderMap::new();
    if let Some(demanded) = dpop.accept_nonce(url, &response_headers)
        && Some(demanded.as_str()) != sent.as_deref()
    {
        dpop.authorize_with_nonce(&mut headers, &Method::GET, url, tokens, &demanded)?;
        // ...and send it once more
    }

    Ok(())
}
  • authorize returns the nonce the proof it just signed actually carried — resolving and signing are one step, which a separate lookup could not promise while other requests to the same origin are in flight.
  • accept_nonce adopts the nonce of any response and returns it.
  • authorize_with_nonce puts exactly that nonce in the retry, whatever the shared state has moved on to in the meantime.

Info

Nonces are remembered per origin and per namespace: a token endpoint (§8) and a protected resource (§9) issue unrelated sequences even when one host serves both, so the origin alone cannot be the key.

Reading a DPoP Challenge

A DPoP-protected resource answers 401 with a WWW-Authenticate: DPoP ... challenge whose error and error_description are the RFC 6750 ones. Since v0.9.8, BearerChallenge::parse_scheme reads a challenge under any scheme:

use volga_oauth_client::{BearerChallenge, OAuthError, auth_scheme};

fn read(header: &str) -> Result<(), OAuthError> {
    let challenge = BearerChallenge::parse_scheme(header, auth_scheme::DPOP)?;
    Ok(())
}

with_scheme and scheme render and report it, so a parsed challenge re-renders under the scheme it arrived with. parse is this method with auth_scheme::BEARER.

The two registered DPoP error codes are modelled as well: OAuthErrorCode::UseDpopNonce and OAuthErrorCode::InvalidDpopProof (RFC 9449 §7.1).

Warning

Three v0.9.8 changes are worth checking when upgrading:

  • OAuthErrorCode is #[non_exhaustive], so nothing stops compiling — but code that matched the two DPoP codes as Other(..) no longer matches them. The wire form, as_str and the serde representation are unchanged.
  • TokenSet gained the dpop_jkt field, so code constructing one with a struct literal has to name it (None for a bearer token). The wire form is unchanged for anything that carries no binding, so a persisted entry written by an earlier version still reads back.
  • ClientAuthMethod is no longer Copy — the new PrivateKeyJwt variant carries a signing key. It stays Clone, Debug, PartialEq and Eq.
Last Updated: 9/14/26, 6:45 AM
Prev
Machine-to-Machine Grants