Skip to main content

Passkeys (WebAuthn)

Sign in with biometrics, hardware security keys, or platform authenticators. EdgeBase implements the WebAuthn standard using SimpleWebAuthn on the server side, with a full registration and authentication flow.

Browser/Platform Required

Passkeys require a WebAuthn-capable environment (modern browsers, iOS 16+, Android 9+). The SDK provides the REST API layer; you use the browser's navigator.credentials API or a native WebAuthn library to handle the actual credential ceremony.

How It Works

  1. Register: Authenticated user requests registration options, creates a credential via the platform authenticator, and sends the attestation back to the server
  2. Authenticate: User requests authentication options, signs the challenge via their passkey, and sends the assertion back. The server verifies and creates a session
  3. Manage: Users can list their registered passkeys and delete individual ones

Configuration

Enable passkeys in your edgebase.config.ts:

// edgebase.config.ts
export default defineConfig({
auth: {
passkeys: {
enabled: true,
rpName: 'My App', // Displayed in authenticator UI
rpID: 'example.com', // Your domain (no protocol, no port)
origin: 'https://example.com', // Expected origin(s) for WebAuthn requests
},
},
});

Configuration Options

OptionTypeRequiredDescription
enabledbooleanYesEnable WebAuthn/Passkeys
rpNamestringYesRelying Party name shown in the authenticator prompt
rpIDstringYesRelying Party ID — typically your domain (e.g., example.com)
originstring | string[]YesExpected origin(s) for WebAuthn requests (e.g., https://example.com). Use an array for multiple origins (web + mobile)

Local development example:

passkeys: {
enabled: true,
rpName: 'My App (Dev)',
rpID: 'localhost',
origin: 'http://localhost:3000',
},

Registration Flow

Registration adds a new passkey to an already-authenticated user's account. The user must be signed in first (via email/password, OAuth, etc.).

Step 1: Get Registration Options

Request WebAuthn registration options from the server. The server generates a challenge and returns parameters for the browser's navigator.credentials.create() call.

// 1. Get registration options from EdgeBase
const res = await fetch('/api/auth/passkeys/register-options', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
});
const { options } = await res.json();

// 2. Create credential using the browser WebAuthn API
// (use @simplewebauthn/browser for convenience)
import { startRegistration } from '@simplewebauthn/browser';
const credential = await startRegistration(options);

Step 2: Register the Credential

Send the credential response back to the server for verification and storage.

// 3. Send the credential to EdgeBase for verification
const registerRes = await fetch('/api/auth/passkeys/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify({ response: credential }),
});
const { credentialId } = await registerRes.json();
// Passkey registered successfully

Authentication Flow

Authentication allows a user to sign in using a registered passkey instead of a password.

Step 1: Get Authentication Options

// 1. Request authentication options (public endpoint, no auth required)
const res = await fetch('/api/auth/passkeys/auth-options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'user@example.com' }), // optional — narrows to user's credentials
});
const { options } = await res.json();

// 2. Get assertion from the browser
import { startAuthentication } from '@simplewebauthn/browser';
const assertion = await startAuthentication(options);

Step 2: Authenticate

// 3. Send the assertion to EdgeBase
const authRes = await fetch('/api/auth/passkeys/authenticate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ response: assertion }),
});
const { accessToken, refreshToken, user } = await authRes.json();
// User is now signed in via passkey

Managing Passkeys

List Registered Passkeys

const res = await fetch('/api/auth/passkeys', {
headers: { 'Authorization': `Bearer ${accessToken}` },
});
const { passkeys } = await res.json();
// passkeys: [{ id, credentialId, transports, createdAt }, ...]

Delete a Passkey

await fetch(`/api/auth/passkeys/${encodeURIComponent(credentialId)}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${accessToken}` },
});

REST API

MethodEndpointAuthDescription
POST/auth/passkeys/register-optionsRequiredGenerate WebAuthn registration options
POST/auth/passkeys/registerRequiredVerify attestation and store credential
POST/auth/passkeys/auth-optionsPublicGenerate WebAuthn authentication options
POST/auth/passkeys/authenticatePublicVerify assertion and create session
GET/auth/passkeysRequiredList passkeys for the authenticated user
DELETE/auth/passkeys/:credentialIdRequiredDelete a passkey

Request/Response Examples

POST /auth/passkeys/register-options

// Response
{
"options": {
"challenge": "base64url-encoded-challenge",
"rp": { "name": "My App", "id": "example.com" },
"user": { "id": "...", "name": "user@example.com", "displayName": "User" },
"pubKeyCredParams": [...],
"excludeCredentials": [...]
}
}

POST /auth/passkeys/register

// Request
{ "response": { /* WebAuthn attestation response from navigator.credentials.create() */ } }

// Response
{ "credentialId": "base64url-encoded-credential-id" }

POST /auth/passkeys/auth-options

// Request (email is optional)
{ "email": "user@example.com" }

// Response
{
"options": {
"challenge": "base64url-encoded-challenge",
"rpId": "example.com",
"allowCredentials": [{ "id": "...", "transports": ["internal"] }],
"userVerification": "preferred"
}
}

POST /auth/passkeys/authenticate

// Request
{ "response": { /* WebAuthn assertion response from navigator.credentials.get() */ } }

// Response
{
"accessToken": "eyJ...",
"refreshToken": "eyJ...",
"user": { "id": "...", "email": "user@example.com", ... }
}

Architecture Notes

  • Credential storage: WebAuthn credentials (public key, counter, transports) are stored in D1 (AUTH_DB) in the _webauthn_credentials table
  • D1 index: A _passkey_index table in D1 maps credentialId to userId, enabling credential lookup during authentication
  • Challenge management: Registration and authentication challenges are stored in KV with a 5-minute TTL and are single-use (deleted after verification)
  • PKCE-like flow: The server generates challenges and the client proves possession of the private key, similar to the OAuth PKCE pattern

Discoverable Credentials

If you call /auth/passkeys/auth-options without providing an email, the server generates options without allowCredentials restrictions. This enables discoverable credential (resident key) flows where the authenticator presents all available passkeys for the relying party, and the user selects which one to use.

PlatformLibrary
Web (JavaScript)@simplewebauthn/browser
iOS (Swift)AuthenticationServices framework (built-in, iOS 16+)
Android (Kotlin)Credential Manager