Chio/Docs
LOGIN · JOIN

Passkey-Issued Browser Capabilities

Let a browser agent request short-lived authority with a passkey while every signing key stays off the page.

Guide chio@5f63e6e

On this page

A browser agent can carry authority without holding a key. The user's passkey signs a WebAuthn challenge, your issuer exchanges the verified assertion for an envelope it signs, and your host checks that envelope before an operation runs.

The envelope names one audience, a sorted scope set, and a 300-second window. You wire the browser helper, the issuer and its /mint route, and the host verifier, then mint one envelope and admit it.

Start
Passkey custodyChoose a case.
browseryour serversassertion, UVchallenge/mintenvelope κ, 300 spresentadmithauthenticatordevice keyabrowser pageenvelope bytes onlyσissuerissuer key, six gatesKyour hostfour gates, then scope_setoperationbrowseryour serversassertion, UVchallenge/mintenvelope κpresentadmithauthenticatordevice keyabrowser pageenvelope bytesσissuerissuer keyKyour hostaudience pinoperation

The issuer signs, and the browser only carries the envelope

The page asks your issuer for a challenge, the authenticator signs it under user verification, and /mint returns an envelope signed by the issuer key, live for 300 seconds. Your host checks its signature, audience, and expiry, then matches the operation against scope_set. Capabilities.

One passkey issuance from the browser page to your host: who holds each key, where the issuer's gates run before it signs, and where your host checks the envelope. The challenge values are illustrative; the WebAuthn ceremony's internal checks and any tool capability your host issues after the scope check are left out.

Each key stays where it was made. The device key never leaves the authenticator, the issuer key stays on your server, and the page holds only envelope bytes it can present to one audience inside one window. The assertion proves possession and user presence; the issuer signature is what your host checks, so every admitted envelope traces to a server-side root.

1. Request an envelope from the browser

requestCapability from @chio-protocol/passkey runs the browser half: one navigator.credentials.get and two fetches to your issuer. Pass the relying-party id, the audience the envelope must name, the scopes you need, and the issuer URL.

Before you begin

You need a WebAuthn relying party: a registered credential, a relying-party id, and an origin the authenticator signs for. The issuer requires a user-verifying gesture, so the authenticator must support PIN or biometric verification. Your host needs the audience URI and the issuer public key it will pin.

browser.tstypescript
import { requestCapability } from "@chio-protocol/passkey";

const capability = await requestCapability({
  rpId: "login.larkfield.example",
  audience: "urn:chio:audience:kernel",
  scopes: ["tool:read"],
  issuerUrl: "https://issuer.larkfield.example",
});

// Send the envelope to your host's custody-aware request handler.
console.log(capability);
Output
audience         urn:chio:audience:kernel
credential_id    AAAA
scope_set        ["tool:read"]
iat              2026-04-29T12:00:00Z
exp              2026-04-29T12:05:00Z
challenge_nonce  Q0hBTExFTkdF
signature        e41eb7a4…d409

You should see an envelope with seven fields and an exp five minutes after iat. The helper posts {rp_id, audience, scope_set} to /challenge, has the authenticator sign the returned challenge under user verification, then posts the audience, scope set, challenge nonce, and base64url assertion to /mint.

Before it returns, the helper parses the envelope with parseCapabilityToken, a structural check, and rejects an audience it did not request or a scope outside the requested set. A 401 or 403 becomes a typed RequestCapabilityError, and an unrecognized wire code becomes assertion-rejected. User verification resolves as the caller's option, then the issuer's advertised value, then "required"; the issuer demands the gesture at mint time either way.

The /mint request body
POST /mintjson
{
  "audience": "urn:chio:audience:kernel",
  "scope_set": ["tool:read"],
  "challenge_nonce": "Q0hBTExFTkdF",
  "assertion": {
    "credential_id": "Y3JlZA",
    "raw_id": "Y3JlZA",
    "client_data_json": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0Iiwi...",
    "authenticator_data": "SZYN5YgO...",
    "signature": "MEUCIQ...",
    "user_handle": null
  }
}

2. Build the issuer with durable stores

chio-custody-hw is a library. Construct IssuerService with your signing backend and a writable directory, then mount it behind your own /challenge and /mint routes. Its request and response types are HTTP-shaped, so the call site stays the same across routers.

issuer.rs · at startuprust
use std::path::Path;
use std::sync::Arc;

use chio_core_types::crypto::SigningBackend;
use chio_custody_hw::IssuerService;

let signer: Arc<dyn SigningBackend> = Arc::new(backend);

// Durable revocation and replay stores under `dir`, then refuse to
// construct unless both gates are wired.
let issuer = IssuerService::with_signer("urn:chio:audience:kernel", signer)
    .with_durable_stores(Path::new(dir))?  // revocation.sqlite3 + nonces.sqlite3
    .enforce_revocation_replay()?;

You should get an issuer whose revocation oracle and replay store live in revocation.sqlite3 and nonces.sqlite3 under that directory and survive a restart. Construction fails if either file cannot be opened, and enforce_revocation_replay fails unless both gates are wired. Build every deployed issuer this way: a with_signer-only issuer wires the rate limiter but mints for a revoked credential and accepts a replayed nonce.

with_durable_stores comes from the sqlite-store feature, on by default; a --no-default-features build has neither it nor the WebAuthn verifier. To drive revocations from the same service, take the shared oracle from with_durable_stores_handle, because a second oracle over the same file keeps its own cache. Use Ed25519Backend: the host verifier checks Ed25519 signatures against an Ed25519 public key.

3. Mint the envelope in your /mint route

Your handler verifies the raw assertion with PasskeyVerifier, then passes the VerifiedAssertion and a MintRequest to mint_capability. MintRequest has no assertion field, so the body the browser posts is your handler's type to deserialize.

Your /mint handlerrust
// Verify the WebAuthn assertion, then mint.
let verified = passkey_verifier.verify_assertion(&challenge_state, &assertion, now_unix)?;

let request = MintRequest {
    audience: "urn:chio:audience:kernel".into(),
    scope_set: ScopeSet::new(["tool:read"]),
    challenge_nonce: challenge_nonce.clone(),
};

let response = issuer.mint_capability(&verified, &request, now)?;
let capability = response.capability; // signed, audience-pinned, exp = now + 5 min

You should get a signed envelope pinned to the issuer's audience, with exp 300 seconds after now. Before signing, mint_capability runs six gates in a fixed order: audience pin, user verification, encoding of the credential id and nonce, the per-credential rate limit, revocation, and replay. The signing backend is a constructor argument, so the issuer has no unsigned path. The defaults admit 30 mints per credential per 60 seconds.

Read more in Issuance gates.

4. Verify the envelope in your host, then enforce its scopes

Pin the audience and the issuer public key in PasskeyCapabilityVerifier and call it from the handler that receives the envelope. The first failed gate returns a typed error.

Your host · request handlerrust
use chio_kernel::custody::PasskeyCapabilityVerifier;

let verifier = PasskeyCapabilityVerifier::new(
    "urn:chio:audience:kernel",
    issuer_public_key,
);
verifier.verify(&capability, now)?;
// Then match the requested operation against capability.scope_set.

You should see Ok(()) for a live envelope at its own audience. The verifier runs four gates: an empty signature never verifies, the audience must match, now must fall inside the window, and the signature must verify over the message rebuilt by clearing the signature slot and re-canonicalizing, so any RFC 8785 implementation reproduces the same bytes. It holds no nonce store.

The verifier checks the envelope; your host owns the operation. Match the requested operation against scope_set, and issue any ordinary tool CapabilityToken from your trusted host. The issuer, likewise, authorizes the requested scopes before it mints them.

In Rust · the four admission gates
crates/kernel/chio-kernel/src/custody.rs83-115rust
pub fn verify(
    &self,
    capability: &PasskeyCapability,
    now: DateTime<Utc>,
) -> Result<(), CustodyError> {
    // Gate 1: empty signatures NEVER verify. The kernel
    // refuses to admit an unsigned envelope even if the audience and clock
    // would otherwise let it through.
    if capability.signature.is_empty() {
        return Err(CustodyError::AssertionRejected(
            "PasskeyCapability presented with empty signature".into(),
        ));
    }

    // Gate 2: audience pin.
    capability.require_audience(&self.audience)?;

    // Gate 3: liveness.
    capability.require_live(now)?;

    // Gate 4: cryptographic verification.
    let message = signing_message(capability)?;
    let signature = Signature::from_hex(&capability.signature).map_err(|err| {
        CustodyError::AssertionRejected(format!("signature hex decode failed: {err}"))
    })?;
    if !self.issuer_public_key.verify(&message, &signature) {
        return Err(CustodyError::AssertionRejected(
            "PasskeyCapability signature did not verify against issuer public key".into(),
        ));
    }

    Ok(())
}

5. Mint one envelope and admit it

The captured run wires durable stores, builds a VerifiedAssertion, mints one envelope, and presents it to a PasskeyCapabilityVerifier pinned to the same audience. It uses a fixed key and clock and runs the library gates without a browser ceremony.

passkey-custody · mintcaptured output
minted capability, canonical JSON
{"audience":"urn:chio:audience:kernel","challenge_nonce":"Q0hBTExFTkdF","credential_id":"AAAA","exp":"2026-04-29T12:05:00Z","iat":"2026-04-29T12:00:00Z","scope_set":["tool:read"],"signature":"e41eb7a40b3d4fa213e5b296fdde709ca2f4377f83a891cae7362cc7d0b5e41f4f5d1259418a127ed78ca00461b14d72b52e6b540b1176836e30c8beb7c6d409"}

exp - iat  300 seconds
signature  128 hex characters

kernel admission: four gates passed, audience urn:chio:audience:kernel
exit 0

You should see the envelope keys in RFC 8785 order, exp - iat of exactly 300 seconds, and a signature of 128 lowercase hex characters with no algorithm prefix. In your own integration, require the verifier to pass with an issuer key and audience you selected independently: matching field values alone do not show who signed an envelope.

Every refusal names the gate that stopped it

Each refusal carries a stable urn:chio:error:custody:* code. A second captured run walks four issuance gates and three admission gates and prints the code each returns.

passkey-custody · denycaptured output
issuance gates
  assertion reported no user-verifying gesture
    urn      urn:chio:error:custody:user-verification-required
    message  custody: user verification required (UV bit not set on assertion)
  requested audience is not the issuer pin
    urn      urn:chio:error:custody:audience-mismatch
    message  custody: audience mismatch (expected urn:chio:audience:kernel, found urn:chio:audience:other)
  revoked closure of PARENT is ["PARENT", "CHILD"]
  credential revoked through its parent
    urn      urn:chio:error:custody:credential-revoked
    message  custody: credential revoked
  challenge nonce already redeemed for this credential
    urn      urn:chio:error:custody:replay-detected
    message  custody: replay detected for credential AAAA

admission gates
  signature slot cleared
    urn      urn:chio:error:custody:assertion-rejected
    message  custody: WebAuthn assertion rejected: PasskeyCapability presented with empty signature
  presented to an audience it was not minted for
    urn      urn:chio:error:custody:audience-mismatch
    message  custody: audience mismatch (expected urn:chio:audience:other, found urn:chio:audience:kernel)
  presented one second after exp
    urn      urn:chio:error:custody:capability-expired
    message  custody: capability expired
exit 0

The revoked closure of PARENT line lists the root and every transitively dependent credential, the set an operator fans a cancellation across. The replayed nonce is refused by the same durable store the first mint wrote. user-verification-required stays distinct from a generic rejection: an assertion without a PIN or biometric gesture is possession-only authentication, and the exact code lets you re-prompt without revealing whether the credential was valid.

Read more in Custody error codes.

Custody envelope and issuer reference

PasskeyCapability lives in chio-custody-hw: the record the issuer mints and the browser stores. It is encoded as canonical JSON (RFC 8785, JCS), so the signature is stable across implementations: keys sort by UTF-16 code units, timestamps are RFC 3339 UTC with a Z suffix, and serde field order does not reach the wire.

crates/trust/chio-custody-hw/src/capability.rs100-118rust
pub struct PasskeyCapability {
    /// Logical audience pin (kernel identity URI). Mismatch is fail-closed.
    pub audience: String,
    /// Base64url-no-pad WebAuthn credential id the assertion was bound to.
    pub credential_id: String,
    /// Capability scope set (canonical sorted).
    pub scope_set: ScopeSet,
    /// Issued-at timestamp, verifier clock.
    pub iat: DateTime<Utc>,
    /// Expiry timestamp, fixed at `iat + 5 minutes`.
    pub exp: DateTime<Utc>,
    /// Base64url-no-pad WebAuthn challenge nonce. Keyed for replay detection.
    pub challenge_nonce: String,
    /// Hex-encoded detached signature over the canonical-JSON encoding of
    /// every other field. Empty only in the pre-signing canonical form
    /// (the signed message); the issuer always populates it via its
    /// signing backend before returning the capability.
    pub signature: String,
}

audience

StringRequired

Pinned to the verifier's identity. An envelope minted for audience A and presented to audience B fails closed.

Example"urn:chio:audience:kernel"

scope_set

ScopeSetRequired

A BTreeSet<String> under serde(transparent): a sorted array of unique strings, deduplicated at construction. covers reports whether one set contains another. The browser helper checks the subset itself with a TypeScript Set of the requested scopes.

Example["tool:read"]

exp

DateTime<Utc>Required

Fixed at iat + 300 seconds (CAPABILITY_LIFETIME_SECONDS, capability.rs:40). iat is the instant the caller passes to mint_capability, an injected clock, so a test can pin the window.

Example"2026-04-29T12:05:00Z"

challenge_nonce

StringRequired

The WebAuthn challenge bound to the assertion, base64url without padding. The issuer's nonce store keys on (credential_id, challenge_nonce).

Example"Q0hBTExFTkdF"

signature

StringRequired

Detached hex signature over the canonical JSON of every other field, signed with signature set to "". Empty only in that pre-signing form; an envelope with an empty signature never verifies.

Issuance gates

The verifier wraps webauthn-rs 0.5.2, pinned in the workspace manifest. mint_capability then applies its gates in this order, so a flood is shed at the cheapest gate, a revoked credential never advances the replay store or spends signing budget, and a replayed assertion never produces a signature.

crates/trust/chio-custody-hw/src/issuer.rs29-45rust
//! # Issuance pipeline (fail-closed, ordered)
//!
//! `mint_capability` applies its gates in this order so the cheapest /
//! most abuse-resistant checks run first and an early deny never advances
//! later state:
//!
//! 1. Audience pin match.
//! 2. User-verification bit.
//! 3. Verified credential id and request challenge nonce canonicality.
//! 4. Per-subject rate limit ([`IssuanceRateLimiter`]). A flood is denied
//!    before the oracle, nonce store, or signer are touched.
//! 5. Revocation cascade ([`CredentialRevocationOracle`]). A revoked
//!    credential (or one revoked transitively through its parent) is
//!    denied before recording the nonce or signing.
//! 6. Replay nonce store ([`PasskeyNonceStore`]). A replayed
//!    `(credential_id, challenge_nonce)` is denied before signing.
//! 7. Signature over the canonical-JSON envelope.
OrderGateRefusal
1
Request audience matches the constructor pin
AudienceMismatch
2
User-verification bit set on the assertion
UserVerificationRequired
3
Credential id and nonce are base64url-no-pad within MAX_NONCE_KEY_BYTES (512)
AssertionRejected
4
Per-credential sliding-window rate limit
RateLimited
5
Credential revoked, directly or through a parent
CredentialRevoked
6
(credential_id, challenge_nonce) already seen
ReplayDetected
7
Sign the canonical JSON envelope
none; always signed

The rate limit defaults to DEFAULT_MAX_PER_WINDOW 30 per DEFAULT_WINDOW_SECONDS 60, keyed on the credential id: headroom for an interactive ceremony, a cap on a runaway client.

Audience pinning

Every envelope carries an explicit audience. The issuer's pin is constructor configuration the caller cannot rewrite; the issuer refuses a request for another audience, and the host verifier refuses an envelope presented to any audience but its own. The audience-confusion suite in crates/trust/chio-custody-hw/tests/audience_confusion.rs mints for audience A and asserts that both layers refuse audience B, including after a bit flip at any index of the signed audience field.

Replay stores

Two nonce stores guard two boundaries. The WebAuthn verifier's store keys on (credential_id, challenge), with the challenge recovered from the assertion's clientDataJSON, so a captured assertion fails even if the caller reused challenge state. The issuer's store keys on (credential_id, challenge_nonce), so a replayed mint never yields a second envelope. The replay guard is mint-side: it stops a second envelope from one assertion, while a live envelope stays presentable until exp.

Both stores record only after the cryptographic check passes. Retention is the capability lifetime plus DEFAULT_CLOCK_SKEW_SECONDS (30): the issuer keeps entries to exp + 30s, and the verifier adds the same tolerance to its five-minute ceremony window and again in the store, 60 seconds past the window. record_if_fresh never prunes; the advisory gc_expired sweep does, which keeps replay decisions off the wall clock. SqlitePasskeyNonceStore keeps single use across restarts; the in-memory store serves tests and single-process runs.

Revocation cascade

Before signing, the issuer consults a CredentialRevocationOracle keyed on the credential id (encoded as the sparse-Merkle SubjectId with a fixed epoch nonce) and refuses the next mint within the current oracle epoch. Revocation is one-way and transitive along edges registered with register_dependency, computed synchronously and committed all or nothing. revoked_closure lists the root and every dependent subject for fan-out cancellation of in-flight work.

Revocation gates issuance. An envelope minted before the revocation keeps passing the host's four gates until it expires, which the 300-second exp bounds. SqliteCredentialRevocationOracle persists the leaf set and dependency edges and replays them in insertion order on open, so a revoked credential and its epoch root survive a restart.

Custody error codes

These eight codes are the ones this flow raises, in gate order. Summaries and recovery text come from the registry in spec/errors/registry.yaml. The custody domain holds 21 codes in all; the rest cover Apple App Attest, Google Play Integrity, and the TEE hardware-key path.

URNMeaningRecovery
custody:audience-mismatch
Capability presented to a different audience than it was minted for.
Issue or request a capability whose audience pin matches the verifier identity.
custody:user-verification-required
WebAuthn assertion verified cryptographically but did not report user verification.
Re-attempt the ceremony with a user-verifying gesture (PIN, biometric); custody issuance requires UV.
custody:assertion-rejected
WebAuthn assertion failed structural or signature verification.
Treat the assertion as untrusted; do not retry without a fresh challenge from the issuer.
custody:rate-limited
Capability issuance was denied because the subject exceeded its rate budget.
Retry after the rate window elapses; the mint is denied fail-closed before the revocation oracle, nonce store, or signing backend are consulted.
custody:credential-revoked
WebAuthn credential bound to this capability has been revoked.
Re-enroll the user's authenticator and request a freshly-issued capability.
custody:replay-detected
WebAuthn challenge nonce was previously redeemed for this credential.
Obtain a fresh challenge from the issuer; replayed assertions are denied fail-closed.
custody:capability-expired
Passkey capability expired before the verifier evaluated the request.
Mint a fresh capability; passkey capabilities are pinned to a five-minute lifetime.
custody:internal-encoding
Custody surface failed to canonicalize, encode, or decode an envelope.
Treat as an internal error; do not retry until the encoding bug is resolved. No fresh challenge will help.

Sources

  • The browser helper: sdks/typescript/packages/passkey/src/request.ts, lines 163 to 292.
  • The issuer: crates/trust/chio-custody-hw/src/issuer.rs, lines 387 to 475.
  • The host verifier: crates/kernel/chio-kernel/src/custody.rs, lines 83 to 115.

Next: revoke a credential and watch the next mint stop

Passkey-Issued Browser Capabilities · Chio Docs