BaasixBaasix
GuidesAuthentication

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

  1. Overview
  2. Environment Configuration
  3. API Endpoints
  4. Client Integration (SDK)
  5. Security Notes
  6. 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.com

RP_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"
}
FieldTypeRequiredDescription
responseobjectYesThe PublicKeyCredential returned by navigator.credentials.create()
namestringNoA 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"
}
FieldTypeRequiredDescription
challengeIdstringYesThe challengeId from /authenticate/options
responseobjectYesThe PublicKeyCredential returned by navigator.credentials.get()
authModestringNojwt (default) or cookie
authTypestringNoSession 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 challengeId from /authenticate/options is 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 401 with INVALID_PASSKEY_RESPONSE, avoiding leaking which part of the ceremony failed (helps resist enumeration/timing attacks).
  • No key material ever exposedGET /auth/passkey returns only id, name, and createdAt. 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.


On this page