Passkey Authentication - jra3/mulm GitHub Wiki
Passkey Authentication
WebAuthn/FIDO2 passwordless authentication using @simplewebauthn.
Overview
Passkeys provide biometric authentication (Touch ID, Face ID, Windows Hello) as a third login method alongside password and Google OAuth.
UX Pattern: Conditional UI - passkeys appear in email field autofill (only if registered).
Table of Contents
- Database Schema
- API Routes
- Registration Flow
- Login Flow
- Frontend Implementation
- Configuration
- Testing
Database Schema
webauthn_credentials
CREATE TABLE webauthn_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE,
credential_id TEXT NOT NULL UNIQUE,
public_key BLOB NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,
transports TEXT, -- JSON: ["internal", "usb", "nfc", "ble"]
device_name TEXT, -- User-assigned (e.g., "iPhone", "MacBook Pro")
created_on DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_used_on DATETIME
);
CREATE INDEX idx_webauthn_member_id ON webauthn_credentials (member_id);
CREATE INDEX idx_webauthn_credential_id ON webauthn_credentials (credential_id);
webauthn_challenges
CREATE TABLE webauthn_challenges (
challenge TEXT PRIMARY KEY,
member_id INTEGER, -- NULL for login, present for registration
purpose TEXT NOT NULL, -- 'registration' or 'authentication'
expires_on DATETIME NOT NULL -- 5 minutes from creation
);
Cleanup: Challenges deleted after single use OR 5-minute expiration.
API Routes
Registration (Requires Login):
POST /auth/passkey/register/options- Get challengePOST /auth/passkey/register/verify- Save credential
Login (Public):
POST /auth/passkey/login/options- Get challengePOST /auth/passkey/login/verify- Authenticate
Management (Requires Login):
DELETE /auth/passkey/:id- Remove passkeyPATCH /auth/passkey/:id/name- Rename passkey
Implementation
POST /auth/passkey/register/options
// src/routes/auth.ts
export const passkeyRegisterOptions = async (req: MulmRequest, res: Response) => {
if (!req.viewer) {
return res.status(401).json({ error: 'Not authenticated' });
}
const options = await generateRegistrationOptionsForMember(
req.viewer.id,
req.viewer.contact_email,
req.viewer.display_name
);
res.json(options);
};
POST /auth/passkey/register/verify
export const passkeyRegisterVerify = async (req: MulmRequest, res: Response) => {
if (!req.viewer) {
return res.status(401).json({ error: 'Not authenticated' });
}
const { credential, deviceName } = req.body;
const result = await verifyAndSaveCredential(
req.viewer.id,
credential, // RegistrationResponseJSON from @simplewebauthn/browser
deviceName
);
if (result.verified) {
res.json({ verified: true, credentialId: result.credentialId });
} else {
res.status(400).json({ error: 'Verification failed' });
}
};
POST /auth/passkey/login/verify
export const passkeyLoginVerify = async (req: MulmRequest, res: Response) => {
const credential = req.body;
const result = await verifyCredentialAndAuthenticate(credential);
if (result.verified && result.memberId) {
await regenerateSession(req, res, result.memberId);
res.json({ verified: true });
} else {
res.status(401).json({ error: 'Authentication failed' });
}
};
Registration Flow
sequenceDiagram
participant User
participant Browser
participant App
participant DB
User->>Browser: Click "Add New Passkey"
Browser->>User: Prompt for device name
User->>Browser: Enter "MacBook Pro"
Browser->>App: POST /auth/passkey/register/options
App->>App: Generate challenge
App->>DB: INSERT challenge (5min expiry)
App-->>Browser: Registration options JSON
Browser->>Browser: startRegistration(options)
Browser->>User: Touch ID/Face ID prompt
User->>Browser: Authenticate (biometric)
Browser->>App: POST /auth/passkey/register/verify
App->>App: Verify attestation
App->>DB: INSERT webauthn_credentials
App->>DB: DELETE challenge (single-use)
App-->>Browser: Success
Browser-->>User: Page reload (shows new passkey)
Key functions:
// src/auth/webauthn.ts
export async function generateRegistrationOptionsForMember(
memberId: number,
userEmail: string,
userName: string
): Promise<PublicKeyCredentialCreationOptionsJSON> {
const existingCredentials = await getCredentialsByMember(memberId);
const options = await generateRegistrationOptions({
rpName,
rpID,
userName: userEmail,
userDisplayName: userName,
userID: Buffer.from(String(memberId)),
attestationType: 'none',
excludeCredentials: existingCredentials.map(cred => ({
id: cred.credential_id,
transports: cred.transports ? JSON.parse(cred.transports) : undefined,
})),
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
authenticatorAttachment: 'platform', // Prefer Touch ID/Face ID
},
});
await saveChallenge(options.challenge, 'registration', memberId);
return options;
}
Login Flow
sequenceDiagram
participant User
participant Browser
participant App
participant DB
User->>Browser: Navigate to signin page
Browser->>App: Load signin.pug
App-->>Browser: HTML + conditional UI script
Browser->>App: POST /auth/passkey/login/options
App->>App: Generate challenge
App->>DB: INSERT challenge (5min expiry)
App-->>Browser: Authentication options JSON
Browser->>Browser: startAuthentication(options, true)
Note over Browser: Passkey appears in email autofill
User->>Browser: Click email field
Browser-->>User: Show passkey in dropdown
User->>Browser: Select passkey
Browser->>User: Touch ID/Face ID prompt
User->>Browser: Authenticate
Browser->>App: POST /auth/passkey/login/verify
App->>DB: SELECT credential by ID
App->>App: Verify signature + counter
App->>DB: UPDATE counter + last_used_on
App->>DB: INSERT session
App-->>Browser: Success + Set cookie
Browser-->>User: Redirect home (logged in)
Key functions:
// src/auth/webauthn.ts
export async function verifyCredentialAndAuthenticate(
response: AuthenticationResponseJSON
): Promise<{ verified: boolean; memberId?: number }> {
// 1. Get credential from database
const credentialIdBase64 = Buffer.from(response.id, 'base64url').toString('base64url');
const credential = await getCredentialById(credentialIdBase64);
if (!credential) return { verified: false };
// 2. Get and validate challenge
const clientDataJSON = Buffer.from(response.response.clientDataJSON, 'base64').toString();
const clientData = JSON.parse(clientDataJSON);
const challengeData = await getChallenge(clientData.challenge);
if (!challengeData) return { verified: false };
// 3. Verify signature
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge: challengeData.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential: {
id: credential.credential_id,
publicKey: credential.public_key,
counter: credential.counter,
},
});
if (!verification.verified) return { verified: false };
// 4. Update counter (replay attack prevention)
await updateCredentialCounter(
credential.credential_id,
verification.authenticationInfo.newCounter
);
return { verified: true, memberId: credential.member_id };
}
Frontend Implementation
Signin Page (Conditional UI)
File: src/views/account/signin.pug
+textInput("Email", "email")(autocomplete="username webauthn")
script(type="module").
import { startAuthentication } from 'https://esm.sh/@simplewebauthn/[email protected]';
(async function() {
// Check Conditional UI support
if (!window.PublicKeyCredential?.isConditionalMediationAvailable) return;
if (!await PublicKeyCredential.isConditionalMediationAvailable()) return;
// Get authentication options
const optRes = await fetch('/auth/passkey/login/options', { method: 'POST' });
if (!optRes.ok) return;
const opts = await optRes.json();
// Start conditional UI - passkeys appear in email autofill
const credential = await startAuthentication(opts, true); // true = conditional
// Verify with server
const verRes = await fetch('/auth/passkey/login/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credential)
});
if (verRes.ok) window.location.href = '/';
})();
Key points:
autocomplete="username webauthn"enables autofill UImediation: 'conditional'makes it non-blocking- Fails silently if no passkeys exist (no errors shown)
- Uses ES6 module import from CDN
Account Settings (Management)
File: src/views/account/links.pug
h3 Passkeys
#passkeysList
if credentials && credentials.length > 0
each cred in credentials
.passkey-item(id=`passkey-${cred.id}`)
.font-bold= cred.device_name || 'Unnamed Device'
.text-sm Added #{new Date(cred.created_on).toLocaleDateString()}
if cred.last_used_on
.text-xs Last used #{new Date(cred.last_used_on).toLocaleDateString()}
button.destructive(
hx-delete=`/auth/passkey/${cred.id}`
hx-target=`#passkey-${cred.id}`
hx-confirm="Remove this passkey?"
) Remove
else
.text-gray-500 No passkeys registered yet
button#addPasskeyBtn Add New Passkey
script(type="module").
import { startRegistration } from 'https://esm.sh/@simplewebauthn/[email protected]';
const btn = document.getElementById('addPasskeyBtn');
if (btn && window.PublicKeyCredential) {
btn.addEventListener('click', async () => {
const deviceName = prompt('Name this passkey:');
if (!deviceName) return;
const optRes = await fetch('/auth/passkey/register/options', { method: 'POST' });
const opts = await optRes.json();
const credential = await startRegistration(opts);
await fetch('/auth/passkey/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential, deviceName })
});
window.location.reload();
});
}
HTMX usage:
- Deletion uses
hx-delete(no custom JS needed) - Registration requires JS for WebAuthn API (no alternative)
Configuration
Development (src/config.json):
{
"webauthn": {
"rpName": "BAS BAP Portal",
"rpID": "localhost",
"origin": "http://localhost:4200"
}
}
Production (/mnt/basny-data/app/config/config.production.json):
{
"webauthn": {
"rpName": "BAS BAP Portal",
"rpID": "bap.basny.org",
"origin": "https://bap.basny.org"
}
}
Status: ✅ Production config updated (October 2025)
Important:
rpIDmust match domain (no protocol, no port)- Dev and production passkeys are separate (different rpID)
- Production file must have 600 permissions, owned by UID 1001
Testing
File: src/__tests__/webauthn.test.ts (11 tests)
describe('WebAuthn Database Operations', () => {
it('should save and retrieve credential', async () => {
const id = await saveCredential({
member_id: 1,
credential_id: 'test-cred-123',
public_key: Buffer.from('public-key-bytes'),
counter: 0,
device_name: 'Test Device'
});
const cred = await getCredentialById('test-cred-123');
expect(cred.device_name).toBe('Test Device');
});
it('should delete challenge after single use', async () => {
await saveChallenge('test-challenge', 'authentication');
const first = await getChallenge('test-challenge');
expect(first).toBeDefined();
const second = await getChallenge('test-challenge');
expect(second).toBeNull(); // Already deleted
});
});
Coverage: Full CRUD for credentials and challenges, counter validation, expiration handling.
Core Functions
generateRegistrationOptionsForMember()
// src/auth/webauthn.ts
export async function generateRegistrationOptionsForMember(
memberId: number,
userEmail: string,
userName: string
): Promise<PublicKeyCredentialCreationOptionsJSON> {
const existingCredentials = await getCredentialsByMember(memberId);
const options = await generateRegistrationOptions({
rpName,
rpID,
userName: userEmail,
userDisplayName: userName,
userID: Buffer.from(String(memberId)),
attestationType: 'none',
excludeCredentials: existingCredentials.map(cred => ({
id: cred.credential_id,
transports: cred.transports ? JSON.parse(cred.transports) : undefined,
})),
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
authenticatorAttachment: 'platform',
},
});
await saveChallenge(options.challenge, 'registration', memberId);
return options;
}
verifyAndSaveCredential()
export async function verifyAndSaveCredential(
memberId: number,
response: RegistrationResponseJSON,
deviceName?: string
): Promise<{ verified: boolean; credentialId?: string }> {
// Extract and validate challenge
const clientDataJSON = Buffer.from(response.response.clientDataJSON, 'base64').toString();
const clientData = JSON.parse(clientDataJSON);
const challengeData = await getChallenge(clientData.challenge);
if (!challengeData || challengeData.member_id !== memberId) {
return { verified: false };
}
// Verify attestation
const verification = await verifyRegistrationResponse({
response,
expectedChallenge: challengeData.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
});
if (!verification.verified || !verification.registrationInfo) {
return { verified: false };
}
// Save credential
const { credential } = verification.registrationInfo;
await saveCredential({
member_id: memberId,
credential_id: credential.id,
public_key: Buffer.from(credential.publicKey),
counter: credential.counter,
transports: response.response.transports ? JSON.stringify(response.response.transports) : undefined,
device_name: deviceName,
});
return { verified: true, credentialId: credential.id };
}
verifyCredentialAndAuthenticate()
export async function verifyCredentialAndAuthenticate(
response: AuthenticationResponseJSON
): Promise<{ verified: boolean; memberId?: number }> {
// Get credential from database
const credentialIdBase64 = Buffer.from(response.id, 'base64url').toString('base64url');
const credential = await getCredentialById(credentialIdBase64);
if (!credential) return { verified: false };
// Get and validate challenge
const clientDataJSON = Buffer.from(response.response.clientDataJSON, 'base64').toString();
const clientData = JSON.parse(clientDataJSON);
const challengeData = await getChallenge(clientData.challenge);
if (!challengeData) return { verified: false };
// Verify signature
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge: challengeData.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential: {
id: credential.credential_id,
publicKey: credential.public_key,
counter: credential.counter,
},
});
if (!verification.verified) return { verified: false };
// Update counter (replay attack prevention)
await updateCredentialCounter(
credential.credential_id,
verification.authenticationInfo.newCounter
);
return { verified: true, memberId: credential.member_id };
}
Database Operations
File: src/db/webauthn.ts
// Save new credential
await saveCredential({
member_id: 123,
credential_id: 'base64url-id',
public_key: Buffer.from(publicKeyBytes),
counter: 0,
transports: '["internal"]',
device_name: 'iPhone'
});
// Get all credentials for member
const credentials = await getCredentialsByMember(memberId);
// Get specific credential
const credential = await getCredentialById(credentialId);
// Update counter after authentication
await updateCredentialCounter(credentialId, newCounter);
// Update device name
await updateCredentialDeviceName(id, 'New Name');
// Delete credential
await deleteCredential(id);
// Challenge management
await saveChallenge('challenge', 'registration', memberId);
const challengeData = await getChallenge('challenge'); // Single-use, auto-deletes
Security Features
Counter Validation (Replay Attack Prevention)
const storedCounter = credential.counter;
const newCounter = verification.authenticationInfo.newCounter;
// Counter must increase
if (newCounter <= storedCounter) {
// Possible replay attack - reject!
return { verified: false };
}
await updateCredentialCounter(credentialId, newCounter);
Challenge Single-Use
export async function getChallenge(challenge: string) {
const rows = await query<WebAuthnChallenge>(
'SELECT * FROM webauthn_challenges WHERE challenge = ? AND expires_on > datetime("now")',
[challenge]
);
const challengeData = rows[0] || null;
// Delete after retrieval (single-use)
if (challengeData) {
await deleteOne('webauthn_challenges', { challenge });
}
return challengeData;
}
Rate Limiting
// src/index.ts
router.post("/auth/passkey/login/options", loginRateLimiter, auth.passkeyLoginOptions);
router.post("/auth/passkey/login/verify", loginRateLimiter, auth.passkeyLoginVerify);
// src/middleware/rateLimiter.ts
export const loginRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5,
keyGenerator: (req) => {
// For passkey endpoints, rate limit by IP only (no email in body)
if (req.path?.includes('/passkey/')) {
return getIpKey(req);
}
// Password login: rate limit by IP + email
const email = (req.body as { email?: string })?.email || 'unknown';
return `${getIpKey(req)}:${email}`;
},
skip: (req) => {
// Skip in test environment or for localhost
return process.env.NODE_ENV === 'test' || req.ip === '::1' || req.ip === '127.0.0.1';
}
});
Implementation Files
| File | Lines | Purpose |
|---|---|---|
src/auth/webauthn.ts |
195 | Core WebAuthn logic |
src/db/webauthn.ts |
163 | Database layer |
src/routes/auth.ts |
+153 | Route handlers |
src/__tests__/webauthn.test.ts |
216 | Tests |
db/migrations/031-add-webauthn-support.sql |
47 | Schema |
src/views/account/signin.pug |
+43 | Conditional UI |
src/views/account/links.pug |
+96 | Management UI |
Dependencies:
@simplewebauthn/[email protected]@simplewebauthn/[email protected](CDN import)
Related Documentation
- Authentication & Authorization - Overview of all auth methods
- Database Schema - Complete table structures
**Last Updated: November 2025