passkey-dev #
A pds with passkeys on a public HTTPS origin, reached through a Cloudflare tunnel, and a small OAuth client to sign in to it with. With PASSKEY_DEV_ENTRYWAY_HOST set, an entryway stands in front: it holds the accounts and the passkeys, and the PDS is its member. It is for walking the passkey flows by hand on real devices: a laptop's platform authenticator, a phone, a security key, a password manager. It is the rig for the device matrix (atproto-crates#qem9), where tools/passkey-browser is the automated check.
tools/passkey-dev/up.sh
That builds pds, atproto-pds-admin and entryway with mail, then starts everything:
| Piece | Where | Notes |
|---|---|---|
| The entryway | https://$PASSKEY_DEV_ENTRYWAY_HOST |
Only with an entryway. The accounts' home: sign-up, Settings, the passkeys (its host is the RP ID), the OAuth authorization server, and handles. Invite-only sign-up. Its service DID is did:web: its host. |
| The PDS | https://$PASSKEY_DEV_PDS_HOST |
Standalone: all of the above, with passkeys on and the RP ID set to its host. Behind an entryway: the member, holding repositories and taking the entryway's tokens, registered at its public URL so DID documents name it. |
| The OAuth client | https://$PASSKEY_DEV_APP_HOST |
A web client with its metadata at /oauth-client-metadata.json. |
| A PLC directory | http://localhost:4872 |
The entryway conformance harness's directory, kept in state/plc.json so DIDs outlive a restart. Nothing reaches plc.directory. |
| maildev | http://127.0.0.1:1081/index.html |
Catches the servers' mail: reset codes, notices. Local only, since codes are credentials. |
| cloudflared | Runs the named tunnel, routing each hostname to its local port. |
Handles end in the entryway's host, or the PDS's when there is no entryway. With an entryway, start-up generates its keys the first time, registers the member once, and runs entryway pds check on it, printing identity, trust and callbacks.
When it's ready it prints the URLs, the maildev inbox, and invite codes: unused ones on a standalone PDS, minting three whenever none are left, or three fresh ones from the entryway on every start. Ctrl-C stops everything. state/ keeps the servers' data, the directory, the generated JWT secret and admin passwords, and the logs; delete it to start over. It belongs to one kind of setup, standalone or with an entryway, and start-up refuses the other kind, so delete it when switching. Passkeys made against an old state/ stay in their authenticators, because signals that delete them are off by default. Remove them by hand from the password manager or the OS settings.
node run.mjs admin <args>runsatproto-pds-adminagainst the running PDS with its admin password, e.g.node run.mjs admin passkeys list did:plc:…ornode run.mjs admin invite create --count 5.node run.mjs entryway <args>runs theentrywaybinary with this setup's configuration, e.g.node run.mjs entryway accounts passkeys list did:plc:…ornode run.mjs entryway pds check did:web:….
Setting it up #
You need cloudflared logged in to a zone (cloudflared tunnel login), podman for maildev, Node 22 and a Rust toolchain. If you'd rather not use podman, set PASSKEY_DEV_SMTP_URL to your own mail sink.
-
Create a named tunnel and route the hostnames to it: one for the PDS, one for the client, and one for the entryway if you want one.
cloudflared tunnel create atproto-pds-passkeys cloudflared tunnel route dns atproto-pds-passkeys passkeys-pds.example.com cloudflared tunnel route dns atproto-pds-passkeys passkeys-app.example.com cloudflared tunnel route dns atproto-pds-passkeys passkeys-entryway.example.comUse single-label names under the zone, such as
passkeys-pds.example.com. Cloudflare's free certificate covers only one subdomain level. -
Copy
env.exampleto.env, which git ignores, and setPASSKEY_DEV_TUNNEL,PASSKEY_DEV_PDS_HOSTandPASSKEY_DEV_APP_HOST, plusPASSKEY_DEV_ENTRYWAY_HOSTfor an entryway. AnyPDS_*orENTRYWAY_*setting in.envis passed to that server, over the walkthrough's own. -
Run
tools/passkey-dev/up.sh.
cloudflared doesn't reach the servers directly. It goes through pass-throughs on ports 4873 (PDS) and 4875 (entryway) that add Cache-Control: no-transform to every answer. Cloudflare's Email Address Obfuscation is on by default for a zone. Without that header it rewrites the address on Settings into a decoding script, the page policy refuses the script, and the page shows "[email protected]". Neither server sends that header yet, so a production deployment behind Cloudflare hits the same thing.
The servers can be reached from the internet while this runs, so sign-up needs an invite and nothing announces the PDS to a relay. Stop it when you're done.
Walking the flows #
Each step names the behaviour it shows. "The server" below is where the accounts live: the entryway when there is one, otherwise the PDS. The crate READMEs' Passkeys sections describe all of it.
Sign up with only a passkey. Open /account/signup on the server and fill in a handle, an email and an invite code. Then choose "Create the account with a passkey" instead of the password form. The account has no password. Settings shows the passkey with its provider's name, if its AAGUID is one the server knows, and whether it is synced or device-bound. Its only passkey can't be removed; a note stands in Remove's place.
Add another passkey; meet the nudges. In Settings → Passkeys, add one from a second authenticator. While every passkey is device-bound, the section suggests a second device or a synced passkey. It also explains a recovery key, the way back that doesn't depend on this server.
Sign in with a passkey. Sign out. On /account/signin, either choose "Sign in with a passkey", or focus the identifier field and pick the passkey from autofill. Autofill is conditional mediation, so the button cancels a pending autofill request first.
Confirm it's you. Create an app password, change the handle or the email, or remove a passkey. Each sends a session older than ten minutes to /account/verify, which takes the password or a passkey and then returns to the page. An account without a password sees only the passkey.
Approve an application. Open the client and sign in, with a handle or with the field empty. The client then exchanges the code with DPoP, checks the sub against the DID document, and shows getSession's answer from the account's PDS. "Refresh the tokens" and "Sign out" (revocation) are there too.
- On a standalone PDS, the consent page is one page. With a handle, it asks that account to approve, and "Approve with a passkey" does it in one step. With the field empty, it asks who you are, and its identifier field offers passkeys by autofill.
- On an entryway, the consent page runs no script. Signed in at the entryway, it offers "Allow as @handle". Signed out, it links to the entryway's sign-in, where the passkey (button or autofill) signs you in and brings you back to consent. The token comes from the entryway and
getSessionis answered by the member PDS.
Go passwordless, or come back. Settings → Password removes the password once the account holds two passkeys, or one that syncs; "Set a password" adds one back. A passwordless account can't use the legacy password login:
curl -s https://$SERVER/xrpc/com.atproto.server.createSession \
-H 'content-type: application/json' -d '{"identifier":"alice.…","password":"x"}'
That answers AuthenticationRequired and names OAuth and app passwords.
Reset, and the 72-hour hold. Request a reset, read the code in maildev, and use it:
curl -s https://$SERVER/xrpc/com.atproto.server.requestPasswordReset \
-H 'content-type: application/json' -d '{"email":"alice@example.org"}'
curl -s https://$SERVER/xrpc/com.atproto.server.resetPassword \
-H 'content-type: application/json' -d '{"token":"<code>","password":"a new password"}'
Settings now carries the recovery-hold banner. For 72 hours passkeys can't be removed and the email can't change, and the holder has been mailed. A reset also gives a passwordless account its password back.
The operator's support path. On a standalone PDS, node run.mjs admin passkeys list <did> lists the account's passkeys, and node run.mjs admin passkeys remove <did> <credential-id> --end-sessions removes one, ends every session, and mails the holder. On an entryway the same commands are node run.mjs entryway accounts passkeys list <did> and … remove <did> <credential-id> --end-sessions. For an account with no password, the last passkey goes only with --send-password-reset.
Signals. The server always sends signalCurrentUserDetails, so changing the handle updates the name the password manager shows. The two signals that delete passkeys from the authenticator are off. Turn them on with PDS_PASSKEY_SIGNAL_DELETIONS=true or ENTRYWAY_PASSKEY_SIGNAL_DELETIONS=true in .env, and only with test accounts. iOS 26 has been seen deleting a different account's passkey on the same RP ID when it receives them.