The Password Problem
Every week, another data breach exposes millions of passwords. Users reuse credentials across sites. Phishing attacks trick people into entering passwords on fake login pages. Password managers help, but adoption remains low.
Passkeys solve this. They're phishing-resistant, can't be reused across sites, and nothing secret is ever transmitted or stored on the server. Apple, Google, and Microsoft have all shipped passkey support. It's time for Solid pods to join them.
What Are Passkeys?
Passkeys are the consumer-friendly name for WebAuthn/FIDO2 credentials. They use public-key cryptography:
- Registration: Device generates a key pair, sends public key to server
- Login: Server sends challenge, device signs it with private key
- Verification: Server verifies signature with stored public key
The private key never leaves the user's device (phone, laptop, or security key). There's nothing to phish, nothing to leak in a breach.
Implementation Plan
Dependencies
Add @simplewebauthn/server - the most popular WebAuthn library for Node.js:
npm install @simplewebauthn/server
1. Extend Account Schema
File: src/idp/accounts.js
Add passkey storage to the account object:
// Current schema
{
"id": "uuid",
"username": "alice",
"email": "[email protected]",
"passwordHash": "bcrypt...",
"webId": "https://alice.example.com/profile/card#me",
"podName": "alice",
"createdAt": "2024-01-01T00:00:00Z"
}
// Extended schema
{
// ... existing fields ...
"passkeys": [
{
"credentialId": "base64url-encoded-credential-id",
"publicKey": "base64url-encoded-public-key",
"counter": 0,
"transports": ["internal", "hybrid"],
"createdAt": "2024-01-01T00:00:00Z",
"lastUsed": "2024-01-15T12:00:00Z",
"name": "iPhone" // User-friendly label
}
]
}
Add helper methods to accounts.js:
/**
* Add a passkey credential to an account
*/
export async function addPasskey(accountId, credential) {
const account = await findById(accountId);
if (!account) return false;
account.passkeys = account.passkeys || [];
account.passkeys.push({
credentialId: credential.credentialId,
publicKey: credential.publicKey,
counter: credential.counter,
transports: credential.transports || [],
createdAt: new Date().toISOString(),
lastUsed: null,
name: credential.name || 'Security Key'
});
await saveAccount(account);
return true;
}
/**
* Find account by passkey credential ID
*/
export async function findByCredentialId(credentialId) {
// Search through accounts for matching credential
// Consider adding a _credential_index.json for efficiency
}
/**
* Update passkey counter after successful auth (prevents replay attacks)
*/
export async function updatePasskeyCounter(accountId, credentialId, newCounter) {
const account = await findById(accountId);
const passkey = account.passkeys.find(p => p.credentialId === credentialId);
if (passkey) {
passkey.counter = newCounter;
passkey.lastUsed = new Date().toISOString();
await saveAccount(account);
}
}
2. Create Passkey Endpoints
File: src/idp/passkey.js (new file)
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse
} from '@simplewebauthn/server';
import * as accounts from './accounts.js';
// Configuration - should come from server config
const getRP = (request) => ({
name: 'Solid Pod',
id: request.hostname.split(':')[0], // RP ID is the domain
});
const origin = (request) => `\${request.protocol}://\${request.hostname}`;
// Temporary challenge storage (use session/redis in production)
const challenges = new Map();
/**
* POST /idp/passkey/register/options
* Generate registration options for a logged-in user
*/
export async function registrationOptions(request, reply) {
const accountId = request.session?.accountId;
if (!accountId) {
return reply.code(401).send({ error: 'Must be logged in to register passkey' });
}
const account = await accounts.findById(accountId);
const rp = getRP(request);
const options = await generateRegistrationOptions({
rpName: rp.name,
rpID: rp.id,
userID: account.id,
userName: account.username,
userDisplayName: account.username,
attestationType: 'none', // We don't need attestation
excludeCredentials: (account.passkeys || []).map(pk => ({
id: Buffer.from(pk.credentialId, 'base64url'),
type: 'public-key',
transports: pk.transports
})),
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred'
}
});
// Store challenge for verification
challenges.set(account.id, {
challenge: options.challenge,
type: 'registration',
expires: Date.now() + 60000 // 1 minute
});
return reply.send(options);
}
/**
* POST /idp/passkey/register/verify
* Verify and store the registration response
*/
export async function registrationVerify(request, reply) {
const accountId = request.session?.accountId;
if (!accountId) {
return reply.code(401).send({ error: 'Must be logged in' });
}
const account = await accounts.findById(accountId);
const stored = challenges.get(account.id);
if (!stored || stored.type !== 'registration' || Date.now() > stored.expires) {
return reply.code(400).send({ error: 'Challenge expired or invalid' });
}
const rp = getRP(request);
try {
const verification = await verifyRegistrationResponse({
response: request.body,
expectedChallenge: stored.challenge,
expectedOrigin: origin(request),
expectedRPID: rp.id
});
if (!verification.verified) {
return reply.code(400).send({ error: 'Verification failed' });
}
const { credentialID, credentialPublicKey, counter } = verification.registrationInfo;
await accounts.addPasskey(accountId, {
credentialId: Buffer.from(credentialID).toString('base64url'),
publicKey: Buffer.from(credentialPublicKey).toString('base64url'),
counter,
transports: request.body.response.transports,
name: request.body.name || 'Security Key'
});
challenges.delete(account.id);
return reply.send({ success: true });
} catch (err) {
return reply.code(400).send({ error: err.message });
}
}
/**
* POST /idp/passkey/login/options
* Generate authentication options (can be called before identifying user)
*/
export async function authenticationOptions(request, reply) {
const { username } = request.body || {};
const rp = getRP(request);
let allowCredentials = [];
let accountId = null;
// If username provided, limit to that user's credentials
if (username) {
const account = await accounts.findByUsername(username);
if (account && account.passkeys?.length) {
accountId = account.id;
allowCredentials = account.passkeys.map(pk => ({
id: Buffer.from(pk.credentialId, 'base64url'),
type: 'public-key',
transports: pk.transports
}));
}
}
const options = await generateAuthenticationOptions({
rpID: rp.id,
allowCredentials,
userVerification: 'preferred'
});
// Store challenge - use visitorId for anonymous requests
const challengeKey = accountId || request.body.visitorId || crypto.randomUUID();
challenges.set(challengeKey, {
challenge: options.challenge,
type: 'authentication',
accountId,
expires: Date.now() + 60000
});
return reply.send({ ...options, challengeKey });
}
/**
* POST /idp/passkey/login/verify
* Verify authentication and return session
*/
export async function authenticationVerify(request, reply) {
const { challengeKey } = request.body;
const stored = challenges.get(challengeKey);
if (!stored || stored.type !== 'authentication' || Date.now() > stored.expires) {
return reply.code(400).send({ error: 'Challenge expired or invalid' });
}
// Find account by credential ID
const credentialId = request.body.id;
const account = stored.accountId
? await accounts.findById(stored.accountId)
: await accounts.findByCredentialId(credentialId);
if (!account) {
return reply.code(400).send({ error: 'Unknown credential' });
}
const passkey = account.passkeys.find(
pk => pk.credentialId === credentialId
);
if (!passkey) {
return reply.code(400).send({ error: 'Credential not found' });
}
const rp = getRP(request);
try {
const verification = await verifyAuthenticationResponse({
response: request.body,
expectedChallenge: stored.challenge,
expectedOrigin: origin(request),
expectedRPID: rp.id,
authenticator: {
credentialID: Buffer.from(passkey.credentialId, 'base64url'),
credentialPublicKey: Buffer.from(passkey.publicKey, 'base64url'),
counter: passkey.counter
}
});
if (!verification.verified) {
return reply.code(400).send({ error: 'Verification failed' });
}
// Update counter to prevent replay attacks
await accounts.updatePasskeyCounter(
account.id,
credentialId,
verification.authenticationInfo.newCounter
);
challenges.delete(challengeKey);
// Return account info for session creation
// The interaction handler will use this to complete the OIDC flow
return reply.send({
success: true,
accountId: account.id,
webId: account.webId
});
} catch (err) {
return reply.code(400).send({ error: err.message });
}
}
3. Register Routes
File: src/idp/index.js
Add the passkey routes:
import * as passkey from './passkey.js';
// In the route registration section:
fastify.post('/idp/passkey/register/options', passkey.registrationOptions);
fastify.post('/idp/passkey/register/verify', passkey.registrationVerify);
fastify.post('/idp/passkey/login/options', passkey.authenticationOptions);
fastify.post('/idp/passkey/login/verify', passkey.authenticationVerify);
4. Update Login UI
File: src/idp/views.js
Add passkey support to the login page:
export function loginPage({ uid, error, passkeyEnabled = true }) {
return `<!DOCTYPE html>
<html>
<head>
<title>Login - Solid Pod</title>
<style>
/* ... existing styles ... */
.passkey-section { margin-top: 20px; padding-top: 20px; border-top: 1px solid #ddd; }
.passkey-btn {
width: 100%; padding: 12px; background: #1a73e8; color: white;
border: none; border-radius: 4px; cursor: pointer; font-size: 16px;
display: flex; align-items: center; justify-content: center; gap: 8px;
}
.passkey-btn:hover { background: #1557b0; }
.passkey-btn svg { width: 20px; height: 20px; }
.divider { display: flex; align-items: center; margin: 20px 0; }
.divider::before, .divider::after { content: ''; flex: 1; border-bottom: 1px solid #ddd; }
.divider span { padding: 0 10px; color: #666; font-size: 14px; }
</style>
</head>
<body>
<div class="container">
<h1>Sign In</h1>
\${error ? `<div class="error">\${error}</div>` : ''}
\${passkeyEnabled ? `
<div class="passkey-section">
<button type="button" class="passkey-btn" onclick="loginWithPasskey()">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 1C8.14 1 5 4.14 5 8c0 2.38 1.19 4.47 3 5.74V17a1 1 0 001 1h1v1a1 1 0 001 1h2a1 1 0 001-1v-1h1a1 1 0 001-1v-3.26c1.81-1.27 3-3.36 3-5.74 0-3.86-3.14-7-7-7zm0 2c2.76 0 5 2.24 5 5s-2.24 5-5 5-5-2.24-5-5 2.24-5 5-5z"/>
</svg>
Sign in with Passkey
</button>
</div>
<div class="divider"><span>or</span></div>
` : ''}
<form method="POST" action="/idp/interaction/\${uid}/login">
<input type="text" name="login" placeholder="Username or Email" required>
<input type="password" name="password" placeholder="Password" required>
<button type="submit">Sign In</button>
</form>
</div>
\${passkeyEnabled ? `
<script>
async function loginWithPasskey() {
try {
// Get authentication options
const optionsRes = await fetch('/idp/passkey/login/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ visitorId: crypto.randomUUID() })
});
const options = await optionsRes.json();
// Convert base64url to ArrayBuffer
options.challenge = base64urlToBuffer(options.challenge);
if (options.allowCredentials) {
options.allowCredentials = options.allowCredentials.map(c => ({
...c,
id: base64urlToBuffer(c.id)
}));
}
// Prompt user for passkey
const credential = await navigator.credentials.get({ publicKey: options });
// Send response to server
const verifyRes = await fetch('/idp/passkey/login/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challengeKey: options.challengeKey,
id: credential.id,
rawId: bufferToBase64url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
authenticatorData: bufferToBase64url(credential.response.authenticatorData),
signature: bufferToBase64url(credential.response.signature),
userHandle: credential.response.userHandle
? bufferToBase64url(credential.response.userHandle)
: null
}
})
});
const result = await verifyRes.json();
if (result.success) {
// Complete the OIDC interaction
window.location.href = '/idp/interaction/\${uid}/passkey?accountId=' + result.accountId;
} else {
alert('Passkey authentication failed: ' + result.error);
}
} catch (err) {
if (err.name === 'NotAllowedError') {
// User cancelled
} else {
console.error('Passkey error:', err);
alert('Passkey authentication failed');
}
}
}
function base64urlToBuffer(base64url) {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
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) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');
}
</script>
` : ''}
</body>
</html>`;
}
5. Add Passkey Interaction Handler
File: src/idp/interactions.js
Add a handler for passkey-authenticated logins:
// GET /idp/interaction/:uid/passkey?accountId=...
// Called after successful passkey verification to complete OIDC flow
export async function handlePasskeyLogin(request, reply) {
const { uid } = request.params;
const { accountId } = request.query;
if (!accountId) {
return reply.code(400).send({ error: 'Missing accountId' });
}
const account = await accounts.findById(accountId);
if (!account) {
return reply.code(400).send({ error: 'Account not found' });
}
// Update last login
await accounts.updateLastLogin(accountId);
// Complete the OIDC interaction
const result = {
login: {
accountId: account.id
}
};
const redirectTo = await provider.interactionResult(request.raw, reply.raw, result);
return reply.redirect(redirectTo);
}
6. Configuration
File: src/server.js
Add passkey configuration option:
// In createServer options
{
passkey: true, // Enable passkey authentication (default: true)
}
// Pass to login page
const passkeyEnabled = config.passkey !== false;
Testing Plan
Unit Tests (test/passkey.test.js)
describe('Passkey Authentication', () => {
describe('Registration', () => {
it('should generate registration options for logged-in user');
it('should reject registration options for anonymous user');
it('should verify and store valid registration response');
it('should reject expired challenge');
it('should reject duplicate credential');
});
describe('Authentication', () => {
it('should generate authentication options');
it('should verify valid authentication response');
it('should update counter after successful auth');
it('should reject replayed authentication (counter check)');
it('should reject expired challenge');
});
describe('Account Integration', () => {
it('should add passkey to existing account');
it('should find account by credential ID');
it('should support multiple passkeys per account');
it('should allow login with any registered passkey');
});
});
Manual Testing
- Register passkey: Login with password → Settings → Add Passkey → Use Touch ID/Face ID
- Login with passkey: Click "Sign in with Passkey" → Authenticate with biometric
- Cross-device: Register on phone, login on laptop (via QR code/hybrid transport)
- Security key: Test with YubiKey or similar hardware key
Security Considerations
- Challenge expiration: 60 seconds to prevent replay attacks
- Counter validation: Detect cloned authenticators
- RP ID binding: Credentials bound to domain, prevents phishing
- User verification: Prefer biometric/PIN verification
- Attestation: Set to 'none' for privacy (we trust the authenticator)
Future Enhancements
References
Passkey Registration UI
Users need a way to register passkeys. Three approaches, in order of implementation:
Option 1: Post-login prompt (Phase 1 - MVP)
After successful password login, prompt users to add a passkey:
┌────────────────────────────────────┐
│ │
│ 🔐 Add a Passkey? │
│ │
│ Sign in faster next time with │
│ Touch ID, Face ID, or a │
│ security key. │
│ │
│ [ Add Passkey ] [ Skip ] │
│ │
└────────────────────────────────────┘
Implementation:
- After password login succeeds, check if user has any passkeys
- If not, redirect to
/idp/interaction/:uid/passkey-prompt
- "Add Passkey" triggers registration flow, then completes OIDC interaction
- "Skip" completes OIDC interaction directly
- Store
passkeyPromptDismissed flag to avoid nagging
// In interactions.js, after successful password login:
if (!account.passkeys?.length && !account.passkeyPromptDismissed) {
return renderPasskeyPrompt(reply, uid);
}
Option 2: Account settings page (Phase 2)
Add /idp/settings route for passkey management:
┌────────────────────────────────────┐
│ Account Settings │
│ │
│ Username: alice │
│ Email: [email protected] │
│ WebID: https://alice.example/... │
│ │
│ ─────────────────────────────────│
│ │
│ Passkeys │
│ │
│ 🔑 iPhone (added Jan 5) [X] │
│ 🔑 MacBook (added Jan 8) [X] │
│ │
│ [+ Add new passkey] │
│ │
│ ─────────────────────────────────│
│ │
│ [Change Password] │
│ │
└────────────────────────────────────┘
Features:
- List all registered passkeys with friendly names and dates
- Delete individual passkeys
- Add new passkeys
- Requires active session (cookie-based auth)
Option 3: During pod registration (Phase 3)
Add passkey option to the registration form:
┌────────────────────────────────────┐
│ Create Your Pod │
│ │
│ Username: [____________] │
│ Email: [____________] │
│ Password: [____________] │
│ │
│ ☐ Also set up passkey login │
│ (recommended) │
│ │
│ [ Create Pod ] │
└────────────────────────────────────┘
Future consideration: Passwordless registration (passkey-only accounts)
Implementation Phases
| Phase |
Feature |
Effort |
| 1 |
Post-login prompt + passkey login |
1 day |
| 2 |
Account settings page |
0.5 day |
| 3 |
Registration integration |
0.5 day |
| 4 |
Passwordless accounts |
1 day |
The Password Problem
Every week, another data breach exposes millions of passwords. Users reuse credentials across sites. Phishing attacks trick people into entering passwords on fake login pages. Password managers help, but adoption remains low.
Passkeys solve this. They're phishing-resistant, can't be reused across sites, and nothing secret is ever transmitted or stored on the server. Apple, Google, and Microsoft have all shipped passkey support. It's time for Solid pods to join them.
What Are Passkeys?
Passkeys are the consumer-friendly name for WebAuthn/FIDO2 credentials. They use public-key cryptography:
The private key never leaves the user's device (phone, laptop, or security key). There's nothing to phish, nothing to leak in a breach.
Implementation Plan
Dependencies
Add
@simplewebauthn/server- the most popular WebAuthn library for Node.js:1. Extend Account Schema
File:
src/idp/accounts.jsAdd passkey storage to the account object:
Add helper methods to
accounts.js:2. Create Passkey Endpoints
File:
src/idp/passkey.js(new file)3. Register Routes
File:
src/idp/index.jsAdd the passkey routes:
4. Update Login UI
File:
src/idp/views.jsAdd passkey support to the login page:
5. Add Passkey Interaction Handler
File:
src/idp/interactions.jsAdd a handler for passkey-authenticated logins:
6. Configuration
File:
src/server.jsAdd passkey configuration option:
Testing Plan
Unit Tests (
test/passkey.test.js)Manual Testing
Security Considerations
Future Enhancements
References
Passkey Registration UI
Users need a way to register passkeys. Three approaches, in order of implementation:
Option 1: Post-login prompt (Phase 1 - MVP)
After successful password login, prompt users to add a passkey:
Implementation:
/idp/interaction/:uid/passkey-promptpasskeyPromptDismissedflag to avoid naggingOption 2: Account settings page (Phase 2)
Add
/idp/settingsroute for passkey management:Features:
Option 3: During pod registration (Phase 3)
Add passkey option to the registration form:
Future consideration: Passwordless registration (passkey-only accounts)
Implementation Phases