Passkey Setup
Passkeys provide a modern, passwordless authentication experience using WebAuthn. Users authenticate with biometrics (Face ID, Touch ID, Windows Hello) or security keys instead of passwords. This guide walks you through setting up passkey authentication in a Cloudwerk application.
Overview
Section titled “Overview”The passkey implementation uses:
- KV for temporary challenge storage (with automatic TTL expiration)
- D1 for persistent user and credential storage
- Convention-based routing for automatic endpoint registration
Project Structure
Section titled “Project Structure”After setup, your auth-related files will look like this:
Directoryapp/
Directoryauth/
- config.ts # Auth configuration
- callbacks.ts # Auth lifecycle hooks
Directoryproviders/
- passkey.ts # Passkey provider config
Directorycomponents/
- PasskeyLoginForm.tsx
- PasskeySignupForm.tsx
Directorylogin/
- page.tsx
Directorysignup/
- page.tsx
Directorydashboard/
- middleware.ts # Protected route
- page.tsx
- middleware.ts # Root session middleware
Directorymigrations/
- 0001_auth_tables.sql # Database schema
- wrangler.toml # Cloudflare bindings
Setup Steps
Section titled “Setup Steps”-
Install the auth package
Terminal window pnpm add @cloudwerk/auth -
Create Cloudflare bindings
Add KV and D1 bindings to your
wrangler.toml:# KV namespace for sessions and challenges[[kv_namespaces]]binding = "AUTH_SESSIONS"id = "your-kv-namespace-id"# D1 database for users and credentials[[d1_databases]]binding = "DB"database_name = "my-app-db"database_id = "your-database-id"Create these resources if you haven’t already:
Terminal window # Create KV namespacewrangler kv:namespace create AUTH_SESSIONS# Create D1 databasewrangler d1 create my-app-db -
Create database migration
Create
migrations/0001_auth_tables.sql:-- Users tableCREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY,email TEXT UNIQUE,email_verified TEXT,name TEXT,image TEXT,created_at TEXT NOT NULL DEFAULT (datetime('now')),updated_at TEXT NOT NULL DEFAULT (datetime('now')));CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);-- WebAuthn credentials tableCREATE TABLE IF NOT EXISTS webauthn_credentials (id TEXT PRIMARY KEY,user_id TEXT NOT NULL,public_key TEXT NOT NULL,counter INTEGER NOT NULL DEFAULT 0,aaguid TEXT,transports TEXT,backed_up INTEGER NOT NULL DEFAULT 0,device_type TEXT NOT NULL DEFAULT 'singleDevice',created_at TEXT NOT NULL DEFAULT (datetime('now')),last_used_at TEXT,name TEXT,FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);CREATE INDEX IF NOT EXISTS idx_credentials_user_idON webauthn_credentials(user_id);Run the migration:
Terminal window wrangler d1 migrations apply my-app-db -
Configure the passkey provider
Create
app/auth/providers/passkey.ts:import { defineProvider, passkey } from '@cloudwerk/auth/convention'export default defineProvider(passkey({rpName: 'My App', // Shown in browser promptsrpId: 'localhost', // Your domain (localhost for dev)origin: 'http://localhost:3000', // Full origin URLauthenticatorAttachment: 'platform', // Use device biometricsuserVerification: 'preferred', // Request biometric when availablekvBinding: 'AUTH_SESSIONS', // KV binding name from wrangler.tomld1Binding: 'DB', // D1 binding name from wrangler.toml})) -
Configure auth settings
Create
app/auth/config.ts:import { defineAuthConfig } from '@cloudwerk/auth/convention'export default defineAuthConfig({basePath: '/auth',session: {strategy: 'database',maxAge: 30 * 24 * 60 * 60, // 30 daysupdateAge: 24 * 60 * 60, // Refresh daily},}) -
Add root middleware for session context
Create
app/middleware.ts:import { createCoreAuthMiddleware } from '@cloudwerk/auth/middleware'export const middleware = createCoreAuthMiddleware({strategy: 'database',kvBinding: 'AUTH_SESSIONS',pages: {signIn: '/login',},}) -
Create the client-side components
See the Client Components section below.
-
Create login and signup pages
See the Pages section below.
-
Protect routes with middleware
Create
app/dashboard/middleware.ts:import { authMiddleware } from '@cloudwerk/auth/middleware'export const middleware = authMiddleware({unauthenticatedRedirect: '/login',})
Provider Configuration
Section titled “Provider Configuration”The passkey provider accepts these options:
| Option | Type | Default | Description |
|---|---|---|---|
rpName | string | required | Display name shown in browser prompts |
rpId | string | window.location.hostname | Your domain (no protocol/port) |
origin | string | string[] | window.location.origin | Allowed origins |
kvBinding | string | — | KV binding name for challenges |
d1Binding | string | — | D1 binding name for credentials |
authenticatorAttachment | 'platform' | 'cross-platform' | — | Authenticator type preference |
userVerification | 'required' | 'preferred' | 'discouraged' | 'preferred' | Biometric requirement |
residentKey | 'required' | 'preferred' | 'discouraged' | 'required' | Discoverable credential requirement |
timeout | number | 60000 | Operation timeout (ms) |
Authenticator Attachment
Section titled “Authenticator Attachment”platform- Built-in authenticators only (Touch ID, Face ID, Windows Hello)cross-platform- External authenticators only (USB security keys, phones)undefined- Allow both types
Production Configuration
Section titled “Production Configuration”For production, update your passkey provider with your actual domain:
export default defineProvider( passkey({ rpName: 'My App', rpId: 'myapp.com', // Your production domain origin: 'https://myapp.com', // HTTPS required in production authenticatorAttachment: 'platform', userVerification: 'preferred', kvBinding: 'AUTH_SESSIONS', d1Binding: 'DB', }))Auto-Registered Routes
Section titled “Auto-Registered Routes”The passkey provider automatically registers these API endpoints:
| Endpoint | Method | Description |
|---|---|---|
/auth/passkey/register/options | POST | Get WebAuthn options for registration |
/auth/passkey/register/verify | POST | Verify registration and create credential |
/auth/passkey/authenticate/options | POST | Get WebAuthn options for authentication |
/auth/passkey/authenticate/verify | POST | Verify authentication and create session |
You don’t need to create route files for these - they’re handled automatically.
Client Components
Section titled “Client Components”The WebAuthn API requires client-side JavaScript. Here are complete, working components:
Base64URL Utilities
Section titled “Base64URL Utilities”Both components need these utilities for WebAuthn API compatibility:
function base64UrlToBuffer(base64url: string): ArrayBuffer { const padding = '='.repeat((4 - (base64url.length % 4)) % 4) const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') + padding const binary = atob(base64) const bytes = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i) } return bytes.buffer}
function bufferToBase64Url(buffer: ArrayBuffer): string { const bytes = new Uint8Array(buffer) let binary = '' for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]) } const base64 = btoa(binary) return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}Login Form
Section titled “Login Form”// app/components/PasskeyLoginForm.tsx'use client'
import { useState } from 'hono/jsx'
export default function PasskeyLoginForm() { const [email, setEmail] = useState('') const [error, setError] = useState<string | null>(null) const [loading, setLoading] = useState(false)
const handleSubmit = async (e: Event) => { e.preventDefault() setError(null) setLoading(true)
try { // Step 1: Get authentication options from server const optionsRes = await fetch('/auth/passkey/authenticate/options', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email.trim() }), credentials: 'include', })
if (!optionsRes.ok) { const err = await optionsRes.json().catch(() => ({})) throw new Error(err.error || 'Failed to get authentication options') }
const options = await optionsRes.json()
// Step 2: Call WebAuthn API const credential = await navigator.credentials.get({ publicKey: { ...options, challenge: base64UrlToBuffer(options.challenge), allowCredentials: options.allowCredentials?.map((c) => ({ ...c, id: base64UrlToBuffer(c.id), })), }, }) as PublicKeyCredential | null
if (!credential) { throw new Error('Authentication was cancelled') }
const response = credential.response as AuthenticatorAssertionResponse
// Step 3: Verify with server const verifyRes = await fetch('/auth/passkey/authenticate/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ credential: { id: credential.id, rawId: bufferToBase64Url(credential.rawId), response: { clientDataJSON: bufferToBase64Url(response.clientDataJSON), authenticatorData: bufferToBase64Url(response.authenticatorData), signature: bufferToBase64Url(response.signature), userHandle: response.userHandle ? bufferToBase64Url(response.userHandle) : null, }, type: credential.type, }, }), credentials: 'include', })
if (!verifyRes.ok) { const err = await verifyRes.json().catch(() => ({})) throw new Error(err.error || 'Authentication failed') }
// Success - redirect to dashboard window.location.href = '/dashboard' } catch (err) { setLoading(false) if (err instanceof Error) { if (err.name === 'NotAllowedError') { setError('Authentication was cancelled or not allowed') } else { setError(err.message) } } else { setError('An error occurred') } } }
return ( <form onSubmit={handleSubmit}> {error && <div className="error">{error}</div>}
<label htmlFor="email">Email address</label> <input type="email" id="email" required value={email} onInput={(e) => setEmail(e.target.value)} />
<button type="submit" disabled={loading}> {loading ? 'Authenticating...' : 'Sign in with Passkey'} </button> </form> )}Signup Form
Section titled “Signup Form”// app/components/PasskeySignupForm.tsx'use client'
import { useState } from 'hono/jsx'
export default function PasskeySignupForm() { const [name, setName] = useState('') const [email, setEmail] = useState('') const [error, setError] = useState<string | null>(null) const [loading, setLoading] = useState(false)
const handleSubmit = async (e: Event) => { e.preventDefault() setError(null) setLoading(true)
try { // Step 1: Get registration options from server const optionsRes = await fetch('/auth/passkey/register/options', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email.trim(), name: name.trim() }), credentials: 'include', })
if (!optionsRes.ok) { const err = await optionsRes.json().catch(() => ({})) throw new Error(err.error || 'Failed to get registration options') }
const options = await optionsRes.json()
// Step 2: Call WebAuthn API to create credential const credential = await navigator.credentials.create({ publicKey: { ...options, challenge: base64UrlToBuffer(options.challenge), user: { ...options.user, id: base64UrlToBuffer(options.user.id), }, excludeCredentials: options.excludeCredentials?.map((c) => ({ ...c, id: base64UrlToBuffer(c.id), })), }, }) as PublicKeyCredential | null
if (!credential) { throw new Error('Passkey creation was cancelled') }
const response = credential.response as AuthenticatorAttestationResponse
// Step 3: Verify with server const verifyRes = await fetch('/auth/passkey/register/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ credential: { id: credential.id, rawId: bufferToBase64Url(credential.rawId), response: { clientDataJSON: bufferToBase64Url(response.clientDataJSON), attestationObject: bufferToBase64Url(response.attestationObject), transports: response.getTransports?.() ?? [], }, type: credential.type, }, userId: options.user.id, }), credentials: 'include', })
if (!verifyRes.ok) { const err = await verifyRes.json().catch(() => ({})) throw new Error(err.error || 'Registration failed') }
// Success - redirect to dashboard window.location.href = '/dashboard' } catch (err) { setLoading(false) if (err instanceof Error) { if (err.name === 'NotAllowedError') { setError('Passkey creation was cancelled or not allowed') } else { setError(err.message) } } else { setError('An error occurred') } } }
return ( <form onSubmit={handleSubmit}> {error && <div className="error">{error}</div>}
<label htmlFor="name">Full name</label> <input type="text" id="name" required value={name} onInput={(e) => setName(e.target.value)} placeholder="Jane Smith" />
<label htmlFor="email">Email address</label> <input type="email" id="email" required value={email} onInput={(e) => setEmail(e.target.value)} />
<button type="submit" disabled={loading}> {loading ? 'Creating account...' : 'Create account with Passkey'} </button> </form> )}Login Page
Section titled “Login Page”// app/login/page.tsximport { isAuthenticated, redirect } from '@cloudwerk/auth'import PasskeyLoginForm from '../components/PasskeyLoginForm'
export async function loader() { if (isAuthenticated()) { throw redirect('/dashboard') } return {}}
export default function LoginPage() { return ( <div> <h1>Sign in</h1> <PasskeyLoginForm /> <p> Don't have an account? <a href="/signup">Sign up</a> </p> </div> )}Signup Page
Section titled “Signup Page”// app/signup/page.tsximport { isAuthenticated, redirect } from '@cloudwerk/auth'import PasskeySignupForm from '../components/PasskeySignupForm'
export async function loader() { if (isAuthenticated()) { throw redirect('/dashboard') } return {}}
export default function SignupPage() { return ( <div> <h1>Create account</h1> <PasskeySignupForm /> <p> Already have an account? <a href="/login">Sign in</a> </p> </div> )}Auth Callbacks
Section titled “Auth Callbacks”Customize the authentication flow with callbacks:
// app/auth/callbacks.tsimport { defineAuthCallbacks } from '@cloudwerk/auth/convention'
export default defineAuthCallbacks({ async signIn({ user, account }) { // Called on successful authentication // Return false to deny sign-in console.log(`User ${user.id} signed in via ${account.provider}`) return true },
async session({ session, user }) { // Customize session data return { ...session, data: { ...session.data, role: user.data?.role, }, } },})How It Works
Section titled “How It Works”Registration Flow
Section titled “Registration Flow”- User submits email and name
- Server generates WebAuthn options with a random challenge
- Challenge is stored in KV with 10-minute TTL
- Browser prompts user to create passkey (biometric verification)
- Browser returns attestation with public key
- Server verifies attestation, consumes challenge
- Server creates user in D1 and stores credential
- Server creates session and sets cookie
Authentication Flow
Section titled “Authentication Flow”- User submits email
- Server looks up user’s credentials in D1
- Server generates WebAuthn options with allowed credentials
- Challenge is stored in KV with 10-minute TTL
- Browser prompts user to authenticate (biometric verification)
- Browser returns assertion with signature
- Server verifies signature using stored public key
- Server validates and increments counter (clone detection)
- Server creates session and sets cookie
Security Features
Section titled “Security Features”The passkey implementation includes:
- Single-use challenges - Challenges are deleted after verification
- Challenge expiration - 10-minute TTL prevents replay attacks
- Counter validation - Detects cloned authenticators
- Origin verification - Prevents phishing attacks
- RP ID validation - Ensures correct domain
- Signature verification - Cryptographic proof of possession
Troubleshooting
Section titled “Troubleshooting””Challenge storage not configured”
Section titled “”Challenge storage not configured””Ensure your passkey provider has kvBinding set and the binding exists in wrangler.toml:
passkey({ // ... kvBinding: 'AUTH_SESSIONS', // Must match wrangler.toml})“User not found” during authentication
Section titled ““User not found” during authentication”The email must match an existing user with a registered passkey. Check that:
- User exists in the
userstable - User has a credential in
webauthn_credentialstable
”NotAllowedError” in browser
Section titled “”NotAllowedError” in browser”This usually means:
- User cancelled the passkey prompt
- No passkey exists for this site
- Browser doesn’t support passkeys
- Site is not served over HTTPS (except localhost)
Passkeys not syncing across devices
Section titled “Passkeys not syncing across devices”Ensure you’re using authenticatorAttachment: 'platform' and:
- iOS: iCloud Keychain is enabled
- Android: Google Password Manager is enabled
- macOS: iCloud Keychain or Chrome profile sync
Next Steps
Section titled “Next Steps”- Authentication Overview - Other auth providers (OAuth, credentials)
- Database Guide - D1 database patterns
- Middleware - Advanced middleware configuration