Passkeys as 2FA
You don't have to throw out your existing login to ship passkeys. Add a passkey as asecond factor on top of password (or magic-link, or SSO) — for every login, for sensitive actions only, or risk-based. This is the lowest-effort way to get phishing-resistant MFA into an app that already has working auth.
Two patterns
- Mandatory 2FA — every login requires password + passkey. Use this for high-security apps (banking, admin panels, healthcare).
- Step-up auth — normal login is unchanged, but specific actions (transfer, password change, account deletion) require a passkey verification. The most common pattern, and the one we recommend by default.
Why this is the easiest path
PasskeyBase doesn't need to become your identity store. Your existing Auth0 / Cognito / Firebase / homegrown user table stays in charge of who the user is. PasskeyBase only stores the passkey credentials bound to your user IDs. Two API calls — enroll once, verify on demand — and you're done.
Compliance bonus: this satisfies "phishing-resistant MFA" for PCI-DSS v4.0, CMMC, FedRAMP, and EO 14028. Passwords + SMS doesn't. Passwords + TOTP barely does (and not for FedRAMP High). Passwords + passkey does.
Step 1: enroll a passkey
After the user has authenticated via your existing login, prompt them to add a passkey. This typically lives in account settings as "Add a security key" or in an onboarding flow as "Make your account more secure."
// 1. After your existing password login succeeds, get an enrollment token
// from your backend (which calls the PasskeyBase Management API).
const { enrollmentToken } = await fetch('/api/passkey-enroll', {
method: 'POST',
headers: { Authorization: `Bearer ${sessionToken}` },
}).then(r => r.json());
// 2. Prompt the user to add a passkey as a second factor.
import { PasskeyBase } from '@passkeybase/js';
const pb = new PasskeyBase({
clientId: 'pk_live_abc123',
domain: 'myapp.auth.ing',
});
await pb.enrollPasskey({
enrollmentToken,
name: 'iPhone', // optional label
});The user gets a Face ID / Touch ID / security-key prompt and the credential is stored against their PasskeyBase identity, which is linked to your user ID via the enrollment token. See enrollment for the full token lifecycle.
Step 2: verify on demand
When the user attempts a sensitive action, ask the SDK for a fresh passkey assertion. The browser produces a hardware-signed challenge response — short-lived, bound to the origin, and bound to the action context you supply.
// On a sensitive action (transfer funds, change email, delete account)
// kick off a passkey verification. The user gets a Face ID / Touch ID
// prompt and you get back a short-lived assertion token.
const { assertionToken } = await pb.verifyPasskey({
// Optional: bind this verification to a specific action
purpose: 'transfer_funds',
context: { amount: 5000, recipient: 'acct_123' },
});
// Send the assertion token to your backend along with the action.
await fetch('/api/transfer', {
method: 'POST',
headers: { 'X-Passkey-Assertion': assertionToken },
body: JSON.stringify({ amount: 5000, recipient: 'acct_123' }),
});Step 3: verify on the backend
Your backend confirms the assertion with PasskeyBase before performing the action. This is the line that makes the security guarantee real — never trust the client to say "yes the user proved themselves." The verify endpoint is a single API call.
// Your backend verifies the assertion token with PasskeyBase before
// performing the sensitive action. The verify endpoint confirms the
// token was minted for this user, this purpose, recently, and bound
// to the context you supplied.
import { PasskeyBase } from '@passkeybase/node';
const pb = new PasskeyBase({
clientId: process.env.PASSKEYBASE_CLIENT_ID,
managementToken: process.env.PASSKEYBASE_MGMT_TOKEN,
});
app.post('/api/transfer', async (req, res) => {
const assertion = req.header('X-Passkey-Assertion');
const result = await pb.verifyAssertion(assertion, {
expectedUserId: req.session.userId,
expectedPurpose: 'transfer_funds',
maxAgeSeconds: 60,
expectedContext: req.body,
});
if (!result.valid) {
return res.status(401).json({ error: 'Passkey verification required' });
}
// ...perform the transfer
});Mandatory 2FA variant
For every-login 2FA, do the same flow but invoke verifyPasskey()immediately after your password login completes, before issuing your session cookie. If verification fails or the user has no enrolled passkey, fall back to a secondary path (TOTP, SMS, magic link, or "enroll one now").
Risk-based 2FA
For risk-based flows — only ask for the passkey when the login looks suspicious (new device, new geo, impossible travel, large transaction) — callverifyPasskey() conditionally based on your risk score. Most apps end up here eventually because it minimizes user friction while still catching the high-risk cases.
Compatibility
Works with any existing auth provider. We've tested combinations with:
- Auth0 / Okta CIC
- AWS Cognito
- Firebase Auth
- Supabase Auth
- Clerk
- NextAuth / Auth.js
- Devise (Rails), Django auth, Spring Security, ASP.NET Identity
- Custom session systems
PasskeyBase doesn't issue your session — your existing system does. PasskeyBase just answers the question "did this user just prove possession of their passkey?"
What about passwordless?
Passkeys-as-2FA is a stepping stone. Once a meaningful share of your users has enrolled a passkey, you can flip the default: passkey is the primary login, password is the fallback. Eventually the password is just there for account recovery. Seethe OAuth flow for the passwordless-from-day-one integration path.