Passkeys (WebAuthn)
Baasix supports WebAuthn passkeys as a first-class authentication method — usernameless, phishing-resistant sign-in using a device's platform authenticator (Touch ID, Face ID, Windows Hello) or a roaming security key.
Table of Contents
- Overview
- Environment Configuration
- API Endpoints
- Client Integration (SDK)
- Security Notes
- Troubleshooting
Overview
A passkey is a public/private key pair generated and held by the user's authenticator (device, browser, or security key). The private key never leaves the device. Baasix stores only the public key and credential metadata — there is nothing secret in the baasix_Passkey table to leak.
Two flows are involved:
- Registration (while logged in) — a user attaches a new passkey to their account from a trusted session, e.g. from a Settings → Security screen.
- Authentication (at login) — a user signs in with a passkey without typing an email or password. The browser prompts for the platform authenticator and returns a signed assertion.
Passkey authentication is browser-only — it relies on the WebAuthn browser API, so it isn't available from Node.js or React Native contexts. The SDK throws a clear error if you call auth.passkey.* outside a browser.
Environment Configuration
Passkeys are gated behind AUTH_SERVICES_ENABLED plus three required RP (Relying Party) settings. All three must be set for the feature to activate — if PASSKEY_RP_ID or PASSKEY_ORIGIN is missing, Baasix logs a startup warning and disables passkeys even if PASSKEY is listed in AUTH_SERVICES_ENABLED.
# Enable the passkey auth service
AUTH_SERVICES_ENABLED=LOCAL,PASSKEY
# Relying Party ID — must match your app's effective domain (no scheme, no port)
PASSKEY_RP_ID=example.com
# Relying Party display name — shown in the browser's passkey prompt
PASSKEY_RP_NAME="My App"
# Comma-separated list of allowed origins (scheme + host + port)
PASSKEY_ORIGIN=https://example.com,https://app.example.comRP_ID must match your domain
PASSKEY_RP_ID has to be a valid domain suffix of the origin the browser is running on (e.g. example.com works for
app.example.com). A mismatch causes the browser to silently reject the WebAuthn ceremony. For local development, use
PASSKEY_RP_ID=localhost and PASSKEY_ORIGIN=http://localhost:3000.
API Endpoints
All passkey endpoints live under /auth/passkey. Registration endpoints require an authenticated session (you're attaching a passkey to your own account); authentication endpoints are public (that's the point — you're not logged in yet).
Registration (Authenticated)
Get Registration Options
Endpoint: POST /auth/passkey/register/options
Authentication: Required (Bearer token)
Returns WebAuthn creation options to pass to the browser's navigator.credentials.create() (the SDK wraps this for you).
Response:
{
"options": {
"challenge": "...",
"rp": { "id": "example.com", "name": "My App" },
"user": { "id": "...", "name": "user@example.com", "displayName": "John Doe" },
"pubKeyCredParams": [...]
}
}Verify Registration
Endpoint: POST /auth/passkey/register/verify
Authentication: Required (Bearer token)
Request Body:
{
"response": { "id": "...", "rawId": "...", "response": { ... }, "type": "public-key" },
"name": "MacBook Touch ID"
}| Field | Type | Required | Description |
|---|---|---|---|
| response | object | Yes | The PublicKeyCredential returned by navigator.credentials.create() |
| name | string | No | A friendly label for the passkey (e.g. device name) |
Response:
{
"verified": true,
"passkey": {
"id": "passkey-uuid",
"name": "MacBook Touch ID",
"createdAt": "2026-01-01T00:00:00.000Z"
}
}Authentication (Public)
Get Authentication Options
Endpoint: POST /auth/passkey/authenticate/options
Authentication: Not required
Response:
{
"options": {
"challenge": "...",
"rpId": "example.com",
"allowCredentials": [...]
},
"challengeId": "challenge-uuid"
}challengeId must be echoed back in the verify call below. It ties the assertion to a specific, single-use challenge.
Verify Authentication
Endpoint: POST /auth/passkey/authenticate/verify
Authentication: Not required (this endpoint performs login). Rate-limited like other public auth endpoints.
Request Body:
{
"challengeId": "challenge-uuid",
"response": { "id": "...", "rawId": "...", "response": { ... }, "type": "public-key" },
"authMode": "jwt",
"authType": "web"
}| Field | Type | Required | Description |
|---|---|---|---|
| challengeId | string | Yes | The challengeId from /authenticate/options |
| response | object | Yes | The PublicKeyCredential returned by navigator.credentials.get() |
| authMode | string | No | jwt (default) or cookie |
| authType | string | No | Session type for session limits (mobile, web, default) |
On success, this returns the same full login response shape as POST /auth/login (token, user, role, permissions, tenant) — session limits and account status (suspended/disabled) are enforced exactly like password login.
Managing Passkeys (Authenticated)
List Your Passkeys
Endpoint: GET /auth/passkey
Authentication: Required (Bearer token)
Response:
{
"passkeys": [{ "id": "passkey-uuid", "name": "MacBook Touch ID", "createdAt": "2026-01-01T00:00:00.000Z" }]
}No key material (public keys, counters, credential IDs) is ever returned — only display metadata.
Delete a Passkey
Endpoint: DELETE /auth/passkey/:id
Authentication: Required (Bearer token). You may only delete your own passkeys.
Response:
{ "message": "Passkey removed" }Client Integration (SDK)
The SDK's auth.passkey.* methods wrap the WebAuthn ceremony (via @simplewebauthn/browser, loaded with a dynamic import) so you don't have to call the options/verify endpoints manually.
// While logged in — register a new passkey for the current user
await baasix.auth.passkey.register('MacBook Touch ID');
// At login — no email/password needed
const session = await baasix.auth.passkey.authenticate();
console.log('Logged in as:', session.user.email);
// List the current user's passkeys
const passkeys = await baasix.auth.passkey.list();
// Remove a passkey
await baasix.auth.passkey.remove(passkeys[0].id);These methods only work in a browser environment. Calling them from Node.js or React Native throws a clear error — passkeys are a browser-native feature and have no server-side or native-mobile equivalent in Baasix today.
Discovering Whether Passkeys Are Enabled
Check GET / (or auth.getAuthMethods()) before showing a "Sign in with a passkey" button:
const methods = await baasix.auth.getAuthMethods();
if (methods.passkey) {
// Show the passkey sign-in option
}See Public Discovery in the Authentication overview for the full discovery shape.
Security Notes
- Single-use, short-lived challenges — each
challengeIdfrom/authenticate/optionsis valid for a single verification attempt and expires after 5 minutes. - Session limits and account status enforced — a successful passkey authentication goes through the same session limit checks and account-status checks (suspended/disabled) as password login. A blocked account can't bypass those checks via passkey.
- Generic failure response — invalid or expired assertions return a generic
401withINVALID_PASSKEY_RESPONSE, avoiding leaking which part of the ceremony failed (helps resist enumeration/timing attacks). - No key material ever exposed —
GET /auth/passkeyreturns onlyid,name, andcreatedAt. Public keys and signature counters stay server-side. - Bypasses 2FA — passkey sign-in is treated as a strong possession factor and does not trigger the two-factor challenge, matching better-auth semantics (2FA gates password login specifically).
Troubleshooting
"Passkeys not enabled" / PASSKEY_NOT_ENABLED
Cause: AUTH_SERVICES_ENABLED doesn't include PASSKEY, or PASSKEY_RP_ID / PASSKEY_ORIGIN is missing.
Solution: Confirm all three are set — see Environment Configuration — and check server startup logs for a passkey warning.
Browser rejects the ceremony silently
Cause: PASSKEY_RP_ID doesn't match the page's origin, or the origin isn't listed in PASSKEY_ORIGIN.
Solution: PASSKEY_RP_ID must be the same as, or a registrable parent domain of, the browser's origin. PASSKEY_ORIGIN must contain the exact scheme+host+port the browser is running on.
"Invalid passkey response" / INVALID_PASSKEY_RESPONSE
Cause: The assertion doesn't match any stored credential, the challenge expired, or the challenge was already used.
Solution: Re-run authenticate/options to get a fresh challenge and retry immediately — challenges are single-use and expire after 5 minutes.
Related Documentation
- Authentication API - Discovery, login, and core auth endpoints
- Two-Factor Authentication - TOTP-based 2FA
- SSO & Social Authentication - OAuth providers
- Session Limits Feature - Concurrent session control
- JavaScript SDK -
auth.passkey.*reference