Passkeys Prerequisites
Passkeys are a great way to connect users with your dapp without the friction of seed phrases or browser extensions. Learn more on the smart wallets guide.
For this tutorial we'll be using Smart Account Kit, which handles the WebAuthn ceremony and deploys an OpenZeppelin Smart Account for each of your users.
There's exactly one thing to provision before we can write a line of passkey code: an API key for the relayer. The rest of this page covers the infrastructure that's quietly doing work on your behalf, which is worth understanding even though you won't configure any of it.
OpenZeppelin Relayer
The OpenZeppelin Relayer, running the Stellar Channels plugin, submits transactions on your users' behalf through a pool of managed channel accounts. It takes care of fees, sequence numbers, and source accounts, so your users never need to hold XLM just to write a guestbook message. If you're coming from the EVM world, this is the rough equivalent of a paymaster.
We'll use the hosted Testnet relayer at https://channels.openzeppelin.com/testnet. To authenticate with it, you'll need an API key.
-
Visit https://channels.openzeppelin.com/testnet/gen to generate a Testnet key.
-
Copy the key into your
.envfile:PRIVATE_RELAYER_BASE_URL="https://channels.openzeppelin.com/testnet"PRIVATE_RELAYER_API_KEY=<paste-your-testnet-api-key>
That's the whole prerequisite. Short and sweet!
SvelteKit doesn't decide what's private by looking at a variable's name. Every variable the app uses is declared in src/env.ts (the template ships with this file already filled in), and anything declared there is private unless it's marked public: true. Private variables like these two are only importable by server-side code via $app/env/private, so the relayer key never ships to the browser. The PUBLIC_* variables from .env.example are the ones marked public: true, which makes them importable from $app/env/public. The PRIVATE_ and PUBLIC_ prefixes are just our naming convention, to make each one's audience obvious at a glance.
Calls to the Channels relayer authenticate with the API key in an Authorization: Bearer header. Put that call in the browser and you've handed the key to anyone who opens their network tab, and they can then spend your credits on whatever they like. This tutorial keeps the key server-side by routing every submission through a same-origin /api/send route, which we'll build on the next page.
What's handled for you
A couple of jobs that a passkey dapp genuinely needs are taken care of without any setup on your part. It's worth knowing who is doing that work, though, because "I don't configure it" is not the same as "it isn't happening."
Funding new wallets
Friendbot only funds classic G... accounts, so it can't top up a C... contract address directly. Smart Account Kit does that dance for you: ask it to fund a wallet and it creates a temporary account, funds that from Friendbot, and transfers the XLM on to your user's contract address. There's no funder keypair to generate and no secret key in your .env. We'll see the two places this shows up (at signup, and behind a "Fund Wallet" button) on the frontend page.
Finding a returning user's wallet
When a returning user taps their passkey, your dapp gets a credential ID back from the authenticator. Before it can do anything useful with that, it has to turn the credential ID into the smart account contract address the credential was added to, and that reverse lookup requires indexed network data.
Smart Account Kit ships an indexer client for exactly this, defaulting to Mercury's hosted smart-account-indexer on Testnet and Mainnet. Its read endpoints are public, so there's no account to create, no JWT to fetch, and no indexing program of your own to write and deploy. You can point indexerUrl at any wire-compatible provider, or pass indexerUrl: false to switch discovery off entirely.
The indexer is easy to overlook precisely because you never configure it, so it's worth being explicit about the division of labor. The relayer handles submission and fee sponsorship. The indexer handles the credential-to-address lookup that lets a returning user log in. They're separate pieces doing separate jobs, and an app that turns discovery off will fail to log users in on any device that hasn't seen that passkey before.
The other half of this story is local: we'll configure the kit with IndexedDBStorage so a connected session survives a page reload. That's a per-browser cache sitting in front of the indexer, not a substitute for it.
Troubleshooting
Things that can trip you up while getting this wired up:
- The relayer returns a 401 or a 403. Generate a fresh key at https://channels.openzeppelin.com/testnet/gen. Testnet keys are free to re-issue, so there's no harm in doing this whenever you're suspicious of one.
- Your
PRIVATE_*variables come back undefined. SvelteKit reads.envwhen the dev server starts. If you edited the file whilepnpm devwas running, restart it. - Login can't find a wallet you know you created. You're probably in a different browser or profile than the one you signed up in, with an empty IndexedDB, which means you're exercising the indexer path. Give it a few ledgers to catch up on a freshly deployed wallet, and check your browser console for a failed request to the indexer.
If you get stuck, drop a question in the #passkeys channel on the Stellar Developer Discord. There's usually somebody around who's ready and willing to help out!