A simple and secure WebAuthn/Passkey implementation for Rust.
- ✨ Simple API - Easy-to-use interface for passkey registration and authentication
- 🔐 Multiple Algorithms - Support for EdDSA (Ed25519), ES256/ES384 (P-256/P-384), and RS256/RS384 (RSA)
- 🛡️ Security First - Built-in replay attack protection via signature counters
- 📦 Framework Agnostic - No web framework lock-in, works with any HTTP server
- 🔑 Extensions - Support for
credProps(discoverable credential reporting), PRF (key derivation / E2E encryption) andlargeBlob(blob storage on the authenticator) - 📜 Attestation - Statement verification for
packed,tpm,android-keyandfido-u2f, with opt-in trust path validation against your own roots - 🦀 Pure Rust - Memory-safe implementation with no unsafe code
Add this to your Cargo.toml:
[dependencies]
passki = "0.3"use passki::{AuthenticationOptions, Passki, RegistrationOptions, StoredPasskey};
let passki = Passki::new(
"example.com", // relying party ID (the domain)
&["https://example.com"], // accepted origins
"Example Corp" // name shown in the browser prompt
);
// Registration step 1: issue a challenge
let user_id = b"unique_user_identifier_12345"; // at least 16 bytes
let (registration_challenge, registration_state) = passki.start_passkey_registration(
user_id,
"alice@example.com", // username
"Alice Smith", // display name
RegistrationOptions::default(),
).expect("user_id must be at least 16 bytes");
// Send registration_challenge to the client as JSON, keep registration_state.
// Registration step 2: verify the credential the client created
let mut stored_passkey = passki.finish_passkey_registration(
®istration_credential,
®istration_state,
)?;
// Save stored_passkey in your database, associated with the user.
// Authentication step 1: issue a challenge
let (authentication_challenge, authentication_state) = passki.start_passkey_authentication(
&user_passkeys,
AuthenticationOptions::default(),
);
// Authentication step 2: verify the signature
let result = passki.finish_passkey_authentication(
&authentication_credential,
&authentication_state,
&stored_passkey,
)?;
// Persist the new counter, or replay detection has nothing to compare against.
stored_passkey.counter = result.counter;Passki supports the following COSE algorithms:
- EdDSA (Ed25519) - Algorithm ID: -8
- ES256 (ECDSA with P-256 and SHA-256) - Algorithm ID: -7
- ES384 (ECDSA with P-384 and SHA-384) - Algorithm ID: -35
- RS256 (RSASSA-PKCS1-v1_5 with SHA-256) - Algorithm ID: -257
- RS384 (RSASSA-PKCS1-v1_5 with SHA-384) - Algorithm ID: -258
StoredPasskey::aaguid is the 16-byte identifier of the authenticator model - which YubiKey, which
password manager. Look it up in the FIDO Metadata Service
or a community AAGUID list.
All zero is the common case, and means there is no model to look up: under the default
AttestationConveyancePreference::None the browser zeroes the AAGUID before passing the credential
on. A non-zero value is worth acting on only once it has been validated, which is what
Attestation sets up.
The same StoredPasskey also carries be (backup eligible - the credential is synced rather than
bound to one device) and bs (currently backed up), both straight from the authenticator data
flags.
The credProps extension reports whether the authenticator created a discoverable (resident)
credential - one stored on the device and usable in passwordless flows. Request it during
registration; the result is stored in StoredPasskey::rk.
use passki::{RegistrationExtensions, RegistrationOptions};
// Request credProps during registration
let mut extensions = RegistrationExtensions::default();
extensions.cred_props = Some(true);
let mut options = RegistrationOptions::default();
options.extensions = Some(extensions);
let (challenge, state) = passki.start_passkey_registration(
user_id, username, display_name, options,
)?;
let passkey = passki.finish_passkey_registration(&credential, &state)?;
// passkey.rk == Some(true) → discoverable credential created
// passkey.rk == Some(false) → non-discoverable credential created
// passkey.rk == None → authenticator did not reportThe WebAuthn PRF extension lets a passkey derive deterministic secret bytes from the authenticator's internal HMAC-secret. This is useful for end-to-end encryption, per-user key derivation, and other scenarios where you need a stable secret tied to a specific passkey. Passki passes the outputs through without processing them.
use passki::{
AuthenticationExtensions, AuthenticationOptions, Passki, PrfEval, PrfInput,
RegistrationExtensions, RegistrationOptions,
};
// During registration, probe for PRF support
let mut extensions = RegistrationExtensions::default();
extensions.prf = Some(PrfInput { eval: None });
let mut options = RegistrationOptions::default();
options.extensions = Some(extensions);
let (challenge, state) = passki.start_passkey_registration(
user_id, username, display_name, options,
)?;
// Check client_extension_results.prf.enabled in the credential before calling finish
// to know whether the authenticator supports PRF
// During authentication, request a PRF derivation for a given context
let mut extensions = AuthenticationExtensions::default();
extensions.prf = Some(PrfInput {
eval: Some(PrfEval {
first: Passki::base64_encode(b"my-app-encryption-key-context"),
second: None,
}),
});
let mut options = AuthenticationOptions::default();
options.extensions = Some(extensions);
let (challenge, state) = passki.start_passkey_authentication(&user_passkeys, options);
// result.prf_first contains the derived key bytes (32 bytes)
// The same passkey + same context always yields the same bytesThe largeBlob extension stores
a small opaque blob on the authenticator itself, such as an SSH key or a certificate. Registration
only probes whether the credential can hold one; reads and writes happen in later authentication
ceremonies, one per ceremony, and a write replaces whatever the credential held.
The blob is base64url in both directions, like the PRF inputs and outputs: encode what you write,
and AuthenticationResult::large_blob hands back the decoded bytes.
use passki::{
AuthenticationExtensions, AuthenticationOptions, LargeBlobAuthenticationInput,
LargeBlobRegistrationInput, LargeBlobSupport, Passki, RegistrationExtensions,
RegistrationOptions,
};
// During registration, ask for a credential that can store a blob
let mut extensions = RegistrationExtensions::default();
extensions.large_blob = Some(LargeBlobRegistrationInput {
support: LargeBlobSupport::Preferred,
});
let mut options = RegistrationOptions::default();
options.extensions = Some(extensions);
let (challenge, state) = passki.start_passkey_registration(
user_id, username, display_name, options,
)?;
let passkey = passki.finish_passkey_registration(&credential, &state)?;
// passkey.large_blob_supported == Some(true) → the credential can hold a blob
// Store it: the authenticator only reports this at registration
// During authentication, write a blob
let mut extensions = AuthenticationExtensions::default();
extensions.large_blob = Some(LargeBlobAuthenticationInput::Write(
Passki::base64_encode(b"ssh-ed25519 AAAA..."),
));
let mut options = AuthenticationOptions::default();
options.extensions = Some(extensions);
let (challenge, state) = passki.start_passkey_authentication(&user_passkeys, options);
// result.large_blob_written == Some(true) → the blob was stored
// A later ceremony reads it back
let mut extensions = AuthenticationExtensions::default();
extensions.large_blob = Some(LargeBlobAuthenticationInput::Read);
// result.large_blob contains the decoded bytesLargeBlobSupport::Required fails the registration when the authenticator cannot store a blob;
Preferred creates the credential either way and reports what it got.
Attestation is the authenticator proving its make and model - "genuine YubiKey 5 NFC" rather than "some passkey". Skip this section unless your policy depends on the hardware; most applications accept any passkey, and the defaults are already right for that.
By default the AAGUID is self-asserted. It comes out of authData, which the client controls.
A malicious client can claim any AAGUID and mint an attestation certificate to match, and every
check Passki performs by default will pass - those checks verify the statement against
the certificate the statement itself supplied, which settles internal consistency and nothing else.
Trust path validation closes the gap: the x5c chain is validated against root certificates
you supply out of band. Same shape as TLS - the peer sends leaf and intermediates, you hold
the roots.
Two things are needed, and either one alone is useless:
AttestationConveyancePreference::Direct, so the browser sends a statement at allPasski::with_attestation_trust, so there is something to validate it against
use passki::{
AttestationConveyancePreference, AttestationTrustPolicy, AttestationType, Passki,
RegistrationOptions,
};
const YUBICO_ROOT: &[u8] = include_bytes!("../roots/yubico-u2f-root.der");
let passki = Passki::new("example.com", &["https://example.com"], "Example Corp")
.with_attestation_trust(&[YUBICO_ROOT], AttestationTrustPolicy::VerifyWhenPresent)?;
let mut options = RegistrationOptions::default();
options.attestation = AttestationConveyancePreference::Direct;
let (challenge, state) = passki.start_passkey_registration(
user_id, username, display_name, options,
)?;Requesting attestation makes some browsers show the user an extra consent prompt.
finish_passkey_registration either fails, or returns a StoredPasskey whose attestation_type
records what the statement was worth:
let passkey = passki.finish_passkey_registration(&credential, &state)?;
match passkey.attestation_type {
AttestationType::Basic | AttestationType::AttCa => allow_model(passkey.aaguid),
_ => treat_as_unattested(),
}attestation_type |
Meaning |
|---|---|
None |
fmt was none. No statement to assess. |
SelfAttested |
The credential key signed its own statement. Proves possession, not model. |
Unverified |
An x5c chain arrived but the policy is Ignore, so it was never validated. |
Basic or AttCa |
The chain validated up to one of your roots. aaguid is attested. |
Basic and AttCa differ in whether the vendor issues one certificate per production batch
or one per device. Both mean the chain validated, so treat them alike.
| Policy | Statement with x5c (security key) |
Statement without x5c (synced passkey) |
|---|---|---|
Ignore (default) |
Accepted as Unverified |
Accepted |
VerifyWhenPresent |
Must chain to a root, else UntrustedAttestation |
Accepted |
Required |
Must chain to a root, else UntrustedAttestation |
MissingAttestationChain |
Ignore preserves pre-0.3 behaviour exactly. Required is effectively "security keys only": iCloud
Keychain, Google Password Manager and 1Password return none attestation regardless of what
is requested, so it excludes every phone and laptop passkey.
Anchors act as a vendor whitelist. Install only the Yubico root and a genuine Feitian key is rejected, because its chain ends at a root you do not have.
Anchors are DER root certificates you supply. Passki bundles none and performs no network I/O.
For a fixed set of approved models, embed the vendor roots with include_bytes!. For broad coverage
there is the FIDO Metadata Service, which publishes roots for every certified
authenticator - but consuming it means fetching and verifying a signed JWT on a refresh schedule,
which belongs in your application rather than in this crate.
Name chaining, the signature of every link, notBefore/notAfter, basicConstraints (CA flag
and pathLenConstraint), and keyUsage/keyCertSign when present. An anchor is trusted
by configuration, so its own validity period is not re-checked.
Revocation is not checked: attestation chains have no CRL or OCSP to consult. Certificate signatures are supported for ECDSA P-256/P-384 with SHA-256/384, RSA PKCS#1 v1.5 with SHA-256/384, and Ed25519; RSASSA-PSS is not.
- 🔒 Always use HTTPS in production - browsers refuse WebAuthn on insecure origins
- 🔄 Store the counter returned by each authentication, or cloned authenticators go undetected
- 🔐 Require user verification for sensitive operations
- ⏱️ Keep ceremony timeouts short; the state stored between the two steps expires with them
- Rust 1.85 or later (Edition 2024)
- HTTPS in production (required by WebAuthn specification)
The examples/ directory has complete registration and authentication flows for several
web frameworks: Actix-web | Axum
| Poem
| Rocket | Warp
All examples request both credProps and PRF during registration. Registration reports whether
a resident key was created; authentication accepts an optional key context string (prf_salt)
to derive a 32-byte key.
cargo run --example axum # or actix-web, poem, rocket, warpThen visit http://localhost:3000 in your browser.
WebAuthn has three specification levels published by the W3C, plus extensions that other specifications define. Checkboxes mark features currently implemented in passki.
The initial recommendation. Defined the core protocol:
- Registration ceremony (
create) and authentication ceremony (get) - Challenge generation and binding
- Client data JSON origin verification
- Authenticator data parsing
- COSE public key extraction
- Signature verification (EdDSA/Ed25519, ES256/P-256, ES384/P-384, RS256, RS384)
- Signature counter tracking and replay detection
- Credential exclusion (
excludeCredentials) -
AttestationConveyancePreference(none/indirect/direct) - Attestation object CBOR parsing
- Attestation statement verification (
packed,tpm,android-key,fido-u2f) - rpId hash verification in authenticator data
- UP (user present) flag enforcement
- UV (user verified) flag enforcement
- AAGUID exposure
-
authenticatorAttachment(platform/cross-platform) - Attestation trust path validation
A substantial expansion, still the most widely implemented level today:
- Discoverable credentials / usernameless flows (empty
allowCredentials) -
ResidentKeyRequirement(discouraged/preferred/required) -
enterpriseattestation conveyance preference - Zero-counter authenticator support
-
credPropsextension -
largeBlobextension -
userHandlein authentication response -
transportson credential descriptors
Still under active development:
- PRF extension (
prf) - BE/BS flags (backup eligibility/state)
- Related origin requests
-
RegistrationResponseJSONandAuthenticationResponseJSONrequest shapes - Signal API
-
hints(security-key/client-device/hybrid) -
attestationFormats -
evalByCredentialin theprfextension -
authenticatorDisplayNamein thecredPropsextension -
remoteClientDataJSONextension -
compoundattestation statement format
These extensions are registered in the IANA WebAuthn extension identifiers registry but specified elsewhere, so they are not tied to a WebAuthn level:
-
credProtectextension (CTAP 2.1 §12.1) -
minPinLengthextension (CTAP 2.1 §12.4) -
paymentextension (Secure Payment Confirmation §5)
Contributions are welcome! Please feel free to submit a Pull Request.
This project is licensed under the Apache License, Version 2.0 (LICENSE).
Passki is built on top of aws-lc-rs for cryptographic operations.