Passkeys (WebAuthn)¶
Passkeys replace passwords with public-key cryptography. The user's device holds a private key; the server stores the public key. Authentication is a challenge-response: the server sends a random nonce, the device signs it, and the server verifies the signature.
Passkeys are phishing-resistant because the browser validates the origin before signing. They work with biometrics (fingerprint, Face ID), PINs, or hardware security keys.
Prerequisites¶
The webauthn package is included as a dependency. You also need:
- A
PasskeyMixinmodel for storing credentials PasskeyAdapterMixinon your adapterPASSKEY_ENABLED=Truein config
Setup¶
1. Define the passkey table¶
2. Pass the model to the adapter¶
adapter = SQLModelAdapter(
session_factory=session_factory,
user_model=User,
refresh_token_model=RefreshToken,
passkey_model=Passkey, # enables passkey support
)
3. Configure passkeys¶
config = FullAuthConfig(
SECRET_KEY="your-secret-key",
PASSKEY_ENABLED=True,
PASSKEY_RP_ID="example.com",
PASSKEY_RP_NAME="My App",
PASSKEY_ORIGINS=["https://example.com"],
)
Warning
PASSKEY_RP_ID must be a bare domain (no scheme, no path). PASSKEY_ORIGINS must be full origins including the scheme (e.g. https://example.com). The library validates these at startup.
Tip
For local development, use PASSKEY_RP_ID=localhost and PASSKEY_ORIGINS=["http://localhost:8000"].
Routes¶
All routes are under the auth prefix (default /api/v1/auth):
| Method | Path | Auth required | Description |
|---|---|---|---|
| POST | /passkeys/register/begin |
Yes | Start passkey registration |
| POST | /passkeys/register/complete |
Yes | Finish registration |
| POST | /passkeys/authenticate/begin |
No | Start authentication |
| POST | /passkeys/authenticate/complete |
No | Finish authentication, returns JWT tokens |
| GET | /passkeys |
Yes | List user's passkeys |
| DELETE | /passkeys/{passkey_id} |
Yes | Delete a passkey |
Registration flow¶
Registration adds a new passkey to an existing authenticated user.
Step 1: Call the begin endpoint. The server generates WebAuthn creation options and stores a challenge in the challenge store.
curl -X POST http://localhost:8000/api/v1/auth/passkeys/register/begin \
-H "Authorization: Bearer <access_token>"
Response:
{
"rp": {"name": "My App", "id": "example.com"},
"user": {"id": "...", "name": "user@example.com", "displayName": "user@example.com"},
"challenge": "...",
"challenge_key": "passkey:reg:abc123...",
"pubKeyCredParams": [...],
"excludeCredentials": [...]
}
Step 2: Pass the options to the browser's WebAuthn API. The browser prompts the user for biometrics or a PIN.
Step 3: Send the credential response back to the complete endpoint:
curl -X POST http://localhost:8000/api/v1/auth/passkeys/register/complete \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{
"challenge_key": "passkey:reg:abc123...",
"credential": { ... },
"device_name": "MacBook Pro"
}'
The server verifies the attestation (origin, RP ID, challenge signature) and stores the credential.
Authentication flow¶
Authentication is passwordless and does not require an existing session.
Step 1: Call the begin endpoint. Optionally pass an email to narrow the allowed credentials:
# With email hint (shows specific credentials)
curl -X POST http://localhost:8000/api/v1/auth/passkeys/authenticate/begin \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com"}'
# Without email (discoverable credentials / conditional UI)
curl -X POST http://localhost:8000/api/v1/auth/passkeys/authenticate/begin
Step 2: Pass the options to navigator.credentials.get() in the browser.
Step 3: Send the credential response to the complete endpoint:
curl -X POST http://localhost:8000/api/v1/auth/passkeys/authenticate/complete \
-H "Content-Type: application/json" \
-d '{
"challenge_key": "passkey:auth:def456...",
"credential": { ... }
}'
The server verifies the assertion, checks the sign count (see Clone detection), and returns a JWT token pair.
Note
When an email is provided to authenticate/begin, the response always includes an allowCredentials list (possibly empty). Callers can't distinguish unknown emails from known ones with no passkeys. This prevents user enumeration.
Frontend integration¶
Here's a minimal JavaScript example for registration and authentication:
// --- Registration ---
async function registerPasskey(accessToken) {
// Step 1: get options from server
const resp = await fetch("/api/v1/auth/passkeys/register/begin", {
method: "POST",
headers: { "Authorization": `Bearer ${accessToken}` },
});
const options = await resp.json();
const challengeKey = options.challenge_key;
// Convert base64url fields to ArrayBuffer for the browser API
options.challenge = base64urlToBuffer(options.challenge);
options.user.id = base64urlToBuffer(options.user.id);
// Step 2: prompt user for biometrics
const credential = await navigator.credentials.create({ publicKey: options });
// Step 3: send result back
await fetch("/api/v1/auth/passkeys/register/complete", {
method: "POST",
headers: {
"Authorization": `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
challenge_key: challengeKey,
credential: serializeCredential(credential),
device_name: navigator.userAgent.split(" ").pop(),
}),
});
}
// --- Authentication ---
async function authenticatePasskey() {
const resp = await fetch("/api/v1/auth/passkeys/authenticate/begin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const options = await resp.json();
const challengeKey = options.challenge_key;
options.challenge = base64urlToBuffer(options.challenge);
if (options.allowCredentials) {
options.allowCredentials = options.allowCredentials.map(c => ({
...c,
id: base64urlToBuffer(c.id),
}));
}
const credential = await navigator.credentials.get({ publicKey: options });
const tokenResp = await fetch("/api/v1/auth/passkeys/authenticate/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
challenge_key: challengeKey,
credential: serializeCredential(credential),
}),
});
return await tokenResp.json(); // { access_token, refresh_token, ... }
}
// --- Helpers ---
function base64urlToBuffer(base64url) {
const padding = "=".repeat((4 - base64url.length % 4) % 4);
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/") + padding;
return Uint8Array.from(atob(base64), c => c.charCodeAt(0)).buffer;
}
function bufferToBase64url(buffer) {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
function serializeCredential(cred) {
const result = {
id: cred.id,
rawId: bufferToBase64url(cred.rawId),
type: cred.type,
response: {},
};
if (cred.response.attestationObject) {
result.response.attestationObject = bufferToBase64url(cred.response.attestationObject);
result.response.clientDataJSON = bufferToBase64url(cred.response.clientDataJSON);
result.response.transports = cred.response.getTransports?.() || [];
} else {
result.response.authenticatorData = bufferToBase64url(cred.response.authenticatorData);
result.response.clientDataJSON = bufferToBase64url(cred.response.clientDataJSON);
result.response.signature = bufferToBase64url(cred.response.signature);
result.response.userHandle = cred.response.userHandle
? bufferToBase64url(cred.response.userHandle) : null;
}
return result;
}
Managing passkeys¶
List passkeys¶
Returns a list of registered passkeys with metadata:
[
{
"id": "550e8400-...",
"device_name": "MacBook Pro",
"transports": ["internal"],
"backed_up": true,
"created_at": "2025-01-15T10:30:00",
"last_used_at": "2025-01-20T14:22:00"
}
]
Delete a passkey¶
curl -X DELETE http://localhost:8000/api/v1/auth/passkeys/550e8400-... \
-H "Authorization: Bearer <access_token>"
Returns 204 on success, 404 if not found or not owned by the current user.
Clone detection¶
Hardware authenticators maintain a sign_count that increments on each use. The library uses compare-and-swap to detect cloned authenticators:
- On each authentication, the authenticator reports its current sign count.
- The server only accepts the new count if it's strictly greater than the stored value (atomic CAS via
update_passkey_sign_count()). - If the CAS fails and the reported count is non-zero, authentication is rejected. This means another device already used a higher count, indicating a clone.
Synced passkeys (iCloud Keychain, Google Password Manager) report sign_count=0 and skip this check, since they're designed to exist on multiple devices.
Configuration reference¶
| Setting | Type | Default | Description |
|---|---|---|---|
PASSKEY_ENABLED |
bool | False |
Enable passkey routes |
PASSKEY_RP_ID |
str | None | Relying party ID (bare domain, e.g. example.com) |
PASSKEY_RP_NAME |
str | None | Display name shown in browser prompts |
PASSKEY_ORIGINS |
list[str] | [] |
Expected origins (e.g. ["https://example.com"]) |
PASSKEY_CHALLENGE_BACKEND |
str | "memory" |
"memory" or "redis" |
PASSKEY_CHALLENGE_TTL |
int | 60 |
Challenge expiration in seconds |
PASSKEY_REQUIRE_USER_VERIFICATION |
bool | True |
Require biometric/PIN verification |
All settings use the FULLAUTH_ prefix as environment variables (e.g. FULLAUTH_PASSKEY_ENABLED=true).
Security considerations¶
-
Challenge store backend: the in-memory challenge store is per-process. In multi-worker deployments,
beginandcompletecan hit different workers and the challenge is lost. UsePASSKEY_CHALLENGE_BACKEND=redisin production. -
Rate limiting:
authenticate/beginandauthenticate/completeare rate-limited byAUTH_RATE_LIMIT_PASSKEY_AUTH(default 10 per minute per IP). -
User verification:
PASSKEY_REQUIRE_USER_VERIFICATION=True(default) requires biometric or PIN confirmation on the device. Disabling it allows presence-only checks (just touching the key). -
User enumeration: when an email is passed to
authenticate/begin, the response always returns anallowCredentialslist regardless of whether the email exists. This prevents attackers from probing which emails have passkeys registered.