Passkey-backed Aztec accounts for the browser. The passkey is the signing key: a custom Noir account contract verifies the full WebAuthn envelope in-circuit, so every transaction is authorized by Face ID / fingerprint / PIN with no stored key material. Ephemeral session keys remove the biometric prompt for batch flows after a single on-chain authorization.
Extracted from the production wallet at Apertrue. Apache 2.0.
| Piece | Description |
|---|---|
contract/ |
Aztec-nr account contract (Noir). Two auth paths selected by a witness discriminator: WebAuthn P-256 (full envelope verified in-circuit: clientDataJSON challenge match, SHA-256, authenticatorData concat, ECDSA P-256) and Schnorr-on-Grumpkin session keys authorized via on-chain notes. |
src/wallet.ts |
Passkey registration, P-256 pubkey extraction, account deployment, backup/restore (PBKDF2 310k + AES-256-GCM), diversified addresses. |
src/session-keys.ts |
SessionKeyManager: ephemeral Schnorr keys held in JS memory only, on-chain authorize/revoke, stale-key recovery. |
src/secret-storage.ts |
WebAuthn PRF-derived secrets (preferred) with password and plaintext fallbacks; HKDF domain separation. |
src/pxe.ts |
Browser PXE bootstrap against a remote node: IndexedDB persistence, fail-soft connection, network-restart detection. |
src/sponsored-fpc.ts |
SponsoredFPC fee payment; multiple FPC addresses supported for correlation mitigation. |
src/browser-wallet.ts |
Minimal Wallet bridge between PXE, the account entrypoint, and contract calls. |
src/encryption.ts |
Client-side AES-256-GCM file encryption keyed alongside the wallet (optional). |
import { configure, registerPasskey, getAztecWallet, startSession } from 'aztec-passkey-wallet';
// Once, at app startup
configure({
nodeUrl: 'https://your-node.example.com',
sponsoredFpcAddresses: ['0x...'],
rpName: 'My App',
// rpId: 'example.com', // defaults to the current hostname; set a registrable
// // suffix when the app runs on a subdomain
});
// Create a wallet: passkey + account contract deploy
await registerPasskey();
const { wallet, meta } = await getAztecWallet(); // biometric per TX
// Optional: authorize a session key (one biometric),
// then transact without prompts until expiry
await startSession();Serve the compiled contract artifacts (webauthn_account-WebAuthnAccount.json, SponsoredFPC.json) under contractsBasePath (default /contracts). Build the account contract with contract/build.sh (requires the aztec CLI, which runs nargo, transpiles, and generates verification keys in one step).
The account is cryptographically bound to rpId at deploy, so it must stay stable for the account's lifetime. Changing rpId (or the hostname when it's unset) produces a different account.
What the account contract enforces in-circuit for the WebAuthn path:
- The P-256 signature verifies against the public key fixed at deploy — the private key never leaves the authenticator (secure enclave / platform keystore); the contract stores only the public key.
- The transaction hash is bound as the WebAuthn challenge (checked inside
clientDataJSON). - The assertion's
authenticatorDataUser Verified and User Present flags must be set, so "user verification per transaction" is a circuit-enforced property, not just a client request. - The assertion's rpIdHash must equal the relying-party hash fixed at deploy, binding the account to one relying party rather than trusting the browser/authenticator to refuse other origins.
Off-circuit properties:
- Wallet secrets (Aztec secret key + salt) are derived from the WebAuthn PRF extension where available and held only in page memory — never written to
localStorage/sessionStorage/IndexedDB. A page reload re-derives them from the passkey. Fallbacks when PRF is unavailable: PRF-wrapped IndexedDB storage, password wrapping, or plaintext (with a console warning — avoid). - The PRF-to-field derivation follows RFC 9380 hash-to-field (derive 384 bits, then reduce mod the BN254 prime) so the secret key is statistically uniform.
- Session keys exist only in JS memory and are cleared on logout, page unload, or expiry.
- Backups are AES-256-GCM encrypted with a PBKDF2 (310k iterations) password key.
- Session-key expiry is enforced client-side only — the circuit stores expiry in the note but does not check it. Until
revoke_session_key()nullifies the note on-chain, a stolen session key works past its expiry. Treat a live session key as a bearer credential. - Session-key scope is stored but not enforced — every session key has full account access regardless of the
scopevalue. - A compromised page (XSS) can transact while a session is active, or trigger a WebAuthn prompt the user may approve. The design limits secret exfiltration (no secret at rest to steal), not in-session misuse.
- The
plaintextsecret fallback stores the Aztec secret key unencrypted in IndexedDB on authenticators without PRF or password wrapping. It exists for functionality on limited devices; don't rely on it for high-value accounts. - Max 10 concurrent session keys per account (circuit loop bound); JS memory zeroing is best-effort (the GC may retain copies).
This library has not had an external security audit. Review the circuit and the derivation before using it for anything of value.
All IndexedDB databases and localStorage keys are prefixed with namespace (default aztec-passkey). Key-derivation salts are fixed protocol constants and are deliberately not namespaced — changing them would lock users out of existing wallets.
Built and tested against @aztec/* 4.1.3 (peer dependencies) and aztec-nr v4.1.2 for the contract. Browser proving requires cross-origin isolation (COOP/COEP headers) for multithreaded WASM.
demo/ contains a minimal Vite app: register a passkey, deploy the account, authorize a session key, and send transactions with and without biometrics. See demo/README.md.
Apache 2.0. Contributions welcome.