Passkeys (WebAuthn)
Let members sign in with Face ID, Touch ID, Windows Hello or a security key. Configure the RP ID and origins, run the migration, and learn how the WebAuthn ceremonies work in VitNode.
Passkeys let members sign in with their fingerprint, face or screen lock instead of a password. They are built on WebAuthn, can't be phished, and there is nothing to leak - the server only ever stores a public key.
VitNode ships passkeys in core. Members add them under Settings → Security and use them with Sign in with a passkey on /login. Staff can use the same passkey on the AdminCP login at /admin - see AdminCP passkey sign-in. Under the hood it uses SimpleWebAuthn (@simplewebauthn/server and @simplewebauthn/browser) - the same libraries Better Auth uses - while sessions, users and permissions stay 100% VitNode.
Looking for the member-facing walkthrough? See Using passkeys - it's written so you can link your community straight to it.
Quick start
Passkeys are opt-in: they stay off until you set authorization.passkeys. true turns them on, with everything derived from VITNODE_WEB_URL:
- origin -
VITNODE_WEB_URLitself, e.g.https://community.example.com - RP ID - its hostname, e.g.
community.example.com - RP name -
metadata.shortTitle, falling back tometadata.title
import { buildApiConfig } from "@vitnode/core/vitnode.config"
export const vitNodeApiConfig = buildApiConfig({
authorization: {
passkeys: true,
},
})Need something the defaults can't give you - several origins, or one RP ID shared across subdomains? Pass an object instead, and set only what differs:
authorization: {
passkeys: {
rpId: "example.com",
origins: ["https://example.com", "https://forum.example.com"],
},
},Then run the migration (below) and you're done. The login page shows the passkey button as soon as the API reports passkeys as enabled.
Database migration
Passkeys add two tables to core:
| Table | What's in it |
|---|---|
core_users_passkeys | One row per credential: unique credential ID, COSE public key, signature counter, WebAuthn user handle, transports, device type, backup state, AAGUID, name, timestamps |
core_users_passkey_challenges | Short-lived challenges: a SHA-256 hash of the browser token, the ceremony, the challenge, the user (for registration) and expiresAt |
Both reference core_users with ON DELETE CASCADE. Apply them like any other core migration:
bun run db:migrateIn development pnpm dev runs vitnode db:prepare, which applies it for you.
RP ID, origins and HTTPS
A passkey belongs to one RP ID (a domain). The browser only offers it on pages whose hostname is that domain or a subdomain of it, and VitNode only accepts responses from the origins you list.
- HTTPS is required, with one exception:
localhost(and*.localhost) works over plain HTTP, so local development needs no certificates. - IP addresses can't be RP IDs. Open
http://localhost:3000, nothttp://127.0.0.1:3000- the browser will refuse. - Origins are bare:
https://example.com, nothttps://example.com/orhttps://example.com/login. - Sharing across subdomains: set
rpId: "example.com"and list every origin, e.g.https://example.comandhttps://forum.example.com.
Choose the RP ID before members create passkeys. Changing it later makes every existing passkey unusable - they are cryptographically bound to the old domain. Members would have to sign in another way and add new ones.
VitNode checks the configuration when the API boots:
- A
passkeyssetting that can't work (plain HTTP on a real domain, an RP ID that isn't a parent of an origin, an IP address - including a derived one likeVITNODE_WEB_URL=http://192.168.1.10:3000) fails the boot with a message naming the problem.
Turn passkeys off by removing passkeys, or with passkeys: false. The Sign in with a passkey button disappears from /login, the Security item disappears from settings (/settings/security answers "not found"), and every passkey route answers 404 passkeys_disabled. Saved passkeys stay in the database, so switching passkeys back on brings them straight back.
Prop
Type
How it works
Every ceremony is two requests: options (the server issues a challenge) and verify (the browser answers it). All routes live under /api/@vitnode/core/users/passkeys.
| Method | Path | Who | Does |
|---|---|---|---|
POST | /register/options | signed-in member | Issues creation options and a registration challenge bound to this browser and this member |
POST | /register | signed-in member | Verifies the attestation and saves the passkey |
POST | /sign-in/options | anyone | Issues request options with no allowCredentials, so the browser offers any saved passkey |
POST | /sign-in | anyone | Verifies the assertion and starts a normal session |
POST | /admin-sign-in/options | anyone | Issues a two-minute, AdminCP-only challenge with userVerification: "required" |
POST | /admin-sign-in | anyone | Verifies the assertion, checks staff access live, and starts an AdminCP session only |
GET | / | signed-in member | Lists the member's own passkeys |
PATCH | /{id} | the passkey's owner | Renames it |
DELETE | /{id} | the passkey's owner | Removes it - refused with 409 last_recovery_method if it's the account's last way in |
From the frontend, call them with the typed fetcher - module: "users/passkeys" - like any other core route:
import { fetcherClient } from "@vitnode/core/lib/fetcher-client"
const res = await fetcherClient({
plugin: "@vitnode/core",
method: "get",
module: "users/passkeys",
path: "/",
options: { credentials: "include" },
})Challenges
- Each options call creates a random token, stores only its SHA-256 hash with the challenge, and sets it in an HttpOnly cookie named
<cookieName>_passkey_registration,<cookieName>_passkey_authenticationor<cookieName>_passkey_admin_sign_in(sovitnode_auth_passkey_...by default). - Challenges expire after five minutes - AdminCP sign-in challenges after two. Expired rows are swept every time a new ceremony starts.
- Verification consumes the challenge with a single
DELETE ... RETURNINGscoped to the token hash, the ceremony, "not expired" and - for registration - the signed-in user. It's atomic: two concurrent requests with the same cookie can never both win, and a replayed response always fails. - Because the lookup is by the cookie's token, a challenge can't be used from another browser, by another account, or for another ceremony - a public sign-in challenge never opens the AdminCP, and the other way round.
Verification
The server calls SimpleWebAuthn's verifyRegistrationResponse / verifyAuthenticationResponse with the stored challenge, your configured origins and rpId, and requireUserVerification: true (options also ask for userVerification: "required" and a discoverable credential with residentKey: "required").
On sign-in, the user comes only from the stored credential - anything the browser sends besides the WebAuthn response is ignored. If the authenticator returns a user handle, it must match the one saved with that credential.
Signature counters
- Authenticators that always report
0(most synced passkeys - iCloud Keychain, Google Password Manager) are accepted every time. - Authenticators with a real counter must go up. A counter that stays the same or goes backwards - including dropping back to
0- is rejected as a possible cloned key. - The new counter is written with a compare-and-set (
WHERE counter = <previous>), so two sign-ins racing on one credential can't both succeed.
Sessions
A successful passkey sign-in calls SessionModel.createSessionByUserId() - exactly what password sign-in does - so it sets the usual vitnode_auth cookie on the current device. It never creates an AdminCP session, even for staff. The AdminCP has its own passkey flow at /admin with a separate challenge, a live staff check and its own session - see AdminCP passkey sign-in.
Accounts with AdminCP access can only add a passkey while an AdminCP session for the same user is open in that browser (403 admin_session_required otherwise). Everyone else enrolls as usual.
On the client, usePasskeySignInAction clears identity-specific caches (the AdminCP entries, files, devices and passkeys) before navigating, and keeps the returnTo destination, just like the password form.
Keeping members locked in (the good way)
A member can't remove their last passkey unless the account still has a password or a linked SSO provider. A password only counts while password sign-in is switched on. The check runs in a transaction that locks the user row, so two tabs deleting "one of two" passkeys at once can't leave the account with nothing.
Events
| Event | Payload | Fires when |
|---|---|---|
user.passkey.created | { passkeyId, userId } | A member adds a passkey |
user.passkey.updated | { passkeyId, userId, name } | A member renames a passkey |
user.passkey.deleted | { passkeyId, userId } | A member removes a passkey |
See Built-in events for how to listen to them.
Deployment checklist
- Serve the site over HTTPS on the exact origins you configured. A reverse proxy is fine - what matters is the URL in the member's address bar.
- Set
VITNODE_WEB_URLin every environment, and setrpIdin the config if you share passkeys across subdomains. - Preview deployments on generated hostnames (e.g.
my-app-git-branch.vercel.app) are different origins - passkeys created on production won't work there, and a preview needs its own origin inoriginsto register new ones. - Separate API server? Nothing changes: the API checks the web origin the ceremony ran on, not its own URL. Make sure
cookieDomain/CORS already let the browser send credentialed requests to it. - Custom auth transport? If you registered one with
setAuthTransport, implementstartPasskeySignIn,finishPasskeySignIn,startAdminPasskeySignInandfinishAdminPasskeySignIn, and relay cookies on all four - the challenge cookie is set by the first call and read by the second. - Rate limiting - the options routes create a database row per call. Keep the rate limiter on in production.
Troubleshooting
In development, VitNode logs why a ceremony failed (wrong origin, RP ID mismatch, counter error...) to the API console. The browser only ever receives a generic error code.
| You see | Likely cause |
|---|---|
No passkey button on /login | Passkeys disabled, or the derived config was invalid - check the boot log |
| "The RP ID is neither ... nor a parent domain" | rpId doesn't match the hostname of an origin |
Browser error "invalid domain" / SecurityError | The page's hostname isn't the RP ID or a subdomain of it, or the page isn't HTTPS/localhost |
| "That took a little too long" | The five-minute challenge expired, or the cookie was blocked - start again |
| "Passkey not recognized" | The passkey was removed on the site, or it belongs to another RP ID |