Quickstart
Quickstart: Passkey Wallet (WebAuthn PRF)
Build a passkey-native embedded wallet flow using WebAuthn PRF and iframe isolation.
Recommended first step: load the skill
For best results, install and use the webauthn-prf-wallet skill before implementing this quickstart. It includes proven defaults for PRF derivation, iframe isolation, platform gating, and fallback/recovery behavior.
Install skill
npx skills add 1Shot-API/skills/webauthn-prf-wallet -yWhy this approach
Every embedded wallet vendor — Privy, Thirdweb, Turnkey, Web3Auth — is charging you per-user and per-signature fees to do something your users' browsers already do natively. The WebAuthn PRF extension landed in Chrome, Safari, Firefox, and Edge in 2025. It lets an authenticator — a passkey, Face ID, Touch ID, or a hardware key — produce a deterministic cryptographic output you can use to derive a real EVM private key. No third party involved. No server holding your users' keys. No per-signature bill.
The architecture that works best hosts the wallet logic in a dedicated iframe. That iframe runs in its own JavaScript context, so the derived private key in memory is invisible to the rest of your app — including any third-party scripts or supply-chain dependencies running on the parent page. Your app communicates with the wallet via a narrow postMessage interface: sign this, get the address, unlock the wallet. The key never crosses that boundary.
The same passkey always produces the same wallet address because the derivation is deterministic. Register once, and the user can sign in from any device that has their passkey synced. No seed phrase required. No browser extension. No vendor account.
How the flow works
At registration, your server generates a WebAuthn credential options payload with a 32-byte PRF salt encoded as base64url. The browser calls navigator.credentials.create, the user approves with biometrics, and you send the response back for server-side verification with @simplewebauthn/server. No key material is involved yet — this step is just binding a credential to your relying party.
At sign-in, the wallet iframe fetches authentication options and re-runs the passkey ceremony, this time requesting PRF evaluation with a fixed infoLabel baked into your client code (for example com.yourapp.eth-key-v1). The authenticator returns a 32-byte PRF output. The iframe runs that through HKDF to produce a valid secp256k1 key, constructs a local signer, and caches it in module memory. The parent page receives only the account address — never the key itself.
Every signing operation goes through the iframe. If the wallet isn't already unlocked from a recent ceremony, it triggers a fresh passkey prompt. This means even a compromised app session can't move funds — the attacker still needs the user's biometric.
Pairing with the Public Relayer
Passkeys solve who controls the key. The Public Relayer solves who pays for gas. They're naturally complementary: the wallet iframe produces a signed transaction or EIP-7715 permission payload, and your app submits it to relayer.1shotapi.com which covers the gas and broadcasts on-chain. Your user never needs native ETH in their wallet to complete a transaction.
This separation keeps the architecture clean. The wallet layer stays fully non-custodial — the relayer never has access to the signing key, only to the signed output the user explicitly authorized. You can set sponsorship policies, amount caps, and expiry on the permission, so the relayer only executes within whatever bounds you define. Nothing is open ended.
What you need before you start
This quickstart assumes a web application that can serve a same-origin iframe route (for example /wallet/local) and a server that can store short-lived challenge nonces with a TTL. Redis is the typical choice; any KV store works.
@simplewebauthn/browserand@simplewebauthn/serverfor credential ceremoniesethersorviemfor the local signer inside the iframepostmateor equivalent for cross-frame RPC- Public Relayer access at
relayer.1shotapi.com(permissionless — no account required)
Build sequence
Work through this in order. Each step has a clear deliverable you can verify before moving on.
- Pick your
infoLabelnow and write it down. Something likecom.yourcompany.yourapp.eth-key-v1. This string is part of the key derivation. Changing it after users register changes their wallet address — which looks like account loss. Version it in the string itself and treat it as permanent. - Implement the server endpoints. You need two routes: one that returns registration options with a 32-byte PRF salt as base64url, and one that returns authentication options. Both routes also accept the credential response for server-side verification. Store challenge nonces with a ~60s TTL keyed by a server-generated ID returned to the client.
- Build the wallet iframe. Create the iframe route with minimal dependencies — only what's needed for passkey ceremonies, HKDF derivation, and signing. The iframe exposes a Postmate
Modelwith methods likesignIn,getAccountAddress, andsignMessage. Setallow="publickey-credentials-get publickey-credentials-create"on the iframe element — without it, WebAuthn calls silently fail. - Add the parent-side proxy. The proxy holds the Postmate
ParentAPIhandle and exposes typed methods to the rest of your app. Before calling into the iframe, set the container todisplay: block; visibility: hiddenso the WebAuthn prompt can appear — several browsers refuse to show it if the iframe's ancestor chain hasdisplay: none. - Gate platform support at registration. PRF is not available in every environment — in-app webviews (Facebook, Instagram, LinkedIn) and some mobile browsers don't support it even when the underlying engine does. Check before letting a user register a passkey that won't work. The
webauthn-prf-walletskill ships aisPlatformSupported()helper you can use directly. - Add recovery before launch. Users lose devices. At registration, the iframe can encrypt the derived key client-side with a user-chosen recovery phrase, and your app stores the ciphertext. The server stores something it can't decrypt. You stay fully non-custodial. This step is non-negotiable before you let real users register.
- Wire the relayer. Once the wallet iframe can sign transactions, pipe the signed payload to
POST relayer.1shotapi.com/relayeras arelayer_sendTransactionJSON-RPC call. Callrelayer_getFeeDatafirst to get a quote, include the signed payment approval in the payload, and pollrelayer_getStatusfor confirmation.
The things that trip people up
The infoLabel is permanent. It's part of the HKDF derivation. Change it after launch and every existing user ends up with a different wallet address. Pick it once, put it in a constant with a comment that says exactly that, and never change it.
PRF salt encoding causes silent failures. The server must encode the salt as base64url, not plain base64. The client must decode it back to an ArrayBuffer before handing it to navigator.credentials.create. If you pass a string, the browser silently ignores the PRF extension and clientExtensionResults.prf comes back undefined. The auth ceremony still succeeds — you just can't derive the key.
User activation must survive the postMessage hop. WebAuthn requires transient user activation — the user must have clicked something recently. Call into the iframe synchronously inside your click handler. Wrap it behind an extra setTimeout or unrelated await and the activation window closes before the iframe sees it.
A valid session is not a signing authorization. The passkey ceremony in the iframe is what unlocks the wallet — not the server session. Keep those two things separate in your architecture. The session says who the user is; the passkey says they're present and consenting to sign right now.
You're done when
- Repeated sign-ins with the same passkey produce the same wallet address.
- No logs, network payloads, or telemetry events contain PRF output or the derived private key.
- Wallet operations prompt for biometric approval even when the user has an active app session.
- At least one full transaction completes through the Public Relayer without the user holding native gas.
- A user who registers, then clears all passkeys, can recover their wallet address using the recovery phrase.