Skip to content

Device Signatures

Authkestra supports device-bound signature authentication (proof-of-possession) through the authkestra-devsig crate. This allows you to cryptographically bind sessions or API requests to a specific hardware device, significantly reducing the risk of token theft.

Unlike traditional bearer tokens (like a standard JWT or session cookie) which can be stolen and replayed by an attacker, device signatures require the client to prove possession of a private key for every request.

The server registers the public key (the “Device Identity”) during enrollment. On subsequent requests, the client signs specific request parameters (like the URI, HTTP method, and a nonce) using their private key. The server verifies this signature using the enrolled public key.

Add the authkestra-devsig crate to your dependencies:

[dependencies]
authkestra-devsig = "0.9"
# ...plus the `devsig` feature on whichever adapter you use:
authkestra-axum = { version = "0.9", features = ["devsig"] }
# authkestra-actix = { version = "0.9", features = ["devsig"] }

The easiest way to use authkestra-devsig is via the official web framework integrations (authkestra-axum and authkestra-actix). These integrations provide ready-to-use middleware that intercepts requests, buffers the body to compute the required bdh (body digest hash), and verifies the signatures before your route handlers are ever executed.

To start, construct the DevSig configuration state using the unified typestate builder. You will need a VerifierConfig, an IssuerJwks (a JWKS cache of the issuers trusted to mint attestations), and a ReplayStore (to protect against replay attacks).

use authkestra_devsig::{DevSig, InMemoryReplayStore, IssuerJwks, ReplayStore, VerifierConfig};
use jsonwebtoken::Algorithm;
use std::sync::Arc;
use std::time::Duration;
// `VerifierConfig` is `#[non_exhaustive]`, so build it with `new(...)` rather than a
// struct literal. Arguments, in order:
// trusted_issuers, allowed_algs, max_clock_skew, max_signature_lifetime, audience
let config = VerifierConfig::new(
["https://op.example.test"],
[Algorithm::ES256, Algorithm::EdDSA],
Duration::from_secs(5),
Duration::from_secs(60),
"https://api.example.test",
);
// The JWKS cache does no network I/O of its own — populate it with
// `jwks.insert(issuer, kid, jwk).await` from wherever you fetch and refresh keys.
let jwks = Arc::new(IssuerJwks::new());
let replay_store: Arc<dyn ReplayStore> = Arc::new(InMemoryReplayStore::new());
let devsig = DevSig::builder()
.config(config)
.jwks(jwks)
.replay_store(replay_store)
.build();

In Axum, authkestra-axum provides the DeviceSignatureLayer middleware and an AuthDeviceSignature extractor.

use authkestra_axum::devsig::{DeviceSignatureLayer, AuthDeviceSignature};
use axum::{Router, routing::post, response::IntoResponse};
// Protect the route using the layer
let app: Router<()> = Router::new()
.route("/v1/transfer", post(transfer_handler))
.layer(DeviceSignatureLayer::from(devsig));
// The extractor reads the verified identity populated by the layer
async fn transfer_handler(AuthDeviceSignature(identity): AuthDeviceSignature) -> impl IntoResponse {
format!("Verified request for subject: {}, device: {}", identity.subject, identity.device)
}

By verifying the signature, you ensure that the request was genuinely initiated by the enrolled device, providing high-assurance authentication suitable for sensitive operations.

The verified identity is an authkestra_devsig::DeviceIdentity, and it is #[non_exhaustive] — you cannot build one with a struct expression from your own crate. Use DeviceIdentity::new(subject, device, key_thumbprint, attributes) to unit-test the code that maps an identity onto your own principal or session type, which is where the security-relevant defaults live (for example, an absent role attribute must fall back to your least-privileged value):

use authkestra_devsig::DeviceIdentity;
let identity = DeviceIdentity::new(
"usr_1".to_owned(),
"vk_1".to_owned(),
"jkt-1".to_owned(),
serde_json::json!({}),
);
assert_eq!(my_mapping(&identity).role, "user");

DeviceIdentity::new performs no verification at all: a value built this way is test data and carries no proof that any signature was checked. Only verify() (and therefore the layer/middleware above) produces a DeviceIdentity that means a request passed the algorithm.

Complete, runnable versions of both wirings live in the repository:

Terminal window
cargo run -p authkestra --example axum_devsig --all-features
cargo run -p authkestra --example actix_devsig --all-features