Passkeys
Passkeys let Auth register a WebAuthn credential for a signed-in user and use that credential for login, MFA, or step-up verification.
Auth stores passkey credentials and one-time WebAuthn ceremonies in its Layeron-managed Auth state database through the Database Product.
Enable Passkeys
Section titled “Enable Passkeys”Configure the relying party id and allowed browser origins.
import { auth } from "@layeron/modules"
const appAuth = auth({ passkey: { enabled: true, relyingParty: { id: "example.com", name: "Example App", origins: ["https://app.example.com"], }, userVerification: "preferred", residentKey: "preferred", attestation: "none", login: { enabled: true, allowUsernameless: true, }, mfa: { requireUserVerification: true, }, },})relyingParty.id must match the effective domain used by the browser
credential. Each browser origin that calls WebAuthn must be listed in
relyingParty.origins.
Browser Encoding
Section titled “Browser Encoding”Browsers return WebAuthn binary fields as ArrayBuffer. Auth accepts those
fields as base64url strings in JSON.
function base64url(buffer: ArrayBuffer): string { const bytes = new Uint8Array(buffer) let binary = "" for (const byte of bytes) { binary += String.fromCharCode(byte) }
return btoa(binary) .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/g, "")}
function fromBase64url(value: string): ArrayBuffer { const padded = value .replace(/-/g, "+") .replace(/_/g, "/") .padEnd(Math.ceil(value.length / 4) * 4, "=") const binary = atob(padded) const bytes = new Uint8Array(binary.length) for (let index = 0; index < binary.length; index += 1) { bytes[index] = binary.charCodeAt(index) } return bytes.buffer}
function credentialForAuth(credential: PublicKeyCredential) { const response = credential.response as AuthenticatorAttestationResponse | AuthenticatorAssertionResponse
return { id: credential.id, rawId: base64url(credential.rawId), type: credential.type, authenticatorAttachment: credential.authenticatorAttachment ?? undefined, response: { clientDataJSON: base64url(response.clientDataJSON), attestationObject: "attestationObject" in response ? base64url(response.attestationObject) : undefined, authenticatorData: "authenticatorData" in response ? base64url(response.authenticatorData) : undefined, signature: "signature" in response ? base64url(response.signature) : undefined, userHandle: "userHandle" in response && response.userHandle ? base64url(response.userHandle) : undefined, }, }}Register a Passkey
Section titled “Register a Passkey”Registration requires an active Auth session. A route can call
appAuth.passkey.beginRegistration() and return the WebAuthn options to the
browser.
app.post("/auth/passkeys/register/begin", { auth: "user" }, async (request) => { const body = await request.json() return await appAuth.passkey.beginRegistration({ friendlyName: body.friendlyName, })})On the browser, convert the returned fields before calling
navigator.credentials.create.
const begin = await fetch("/auth/passkeys/register/begin", { method: "POST", credentials: "include", body: JSON.stringify({ friendlyName: "Laptop" }),}).then((response) => response.json())
const credential = await navigator.credentials.create({ publicKey: { ...begin.publicKey, challenge: fromBase64url(begin.publicKey.challenge), user: { ...begin.publicKey.user, id: fromBase64url(begin.publicKey.user.id), }, excludeCredentials: begin.publicKey.excludeCredentials.map((entry) => ({ ...entry, id: fromBase64url(entry.id), })), },})Finish registration by sending the browser credential back to Auth.
app.post("/auth/passkeys/register/finish", { auth: "user" }, async (request) => { const body = await request.json() return await appAuth.passkey.finishRegistration({ ceremonyId: body.ceremonyId, credential: body.credential, friendlyName: body.friendlyName, })})await fetch("/auth/passkeys/register/finish", { method: "POST", credentials: "include", body: JSON.stringify({ ceremonyId: begin.ceremonyId, friendlyName: "Laptop", credential: credentialForAuth(credential as PublicKeyCredential), }),})Auth verifies the challenge, origin, relying party id hash, user presence, configured user verification policy, attested credential id, COSE public key, and supported public key algorithm before storing the passkey.
Sign In With a Passkey
Section titled “Sign In With a Passkey”Start login by email, by user id, or with a usernameless ceremony when
allowUsernameless is enabled.
app.post("/auth/passkeys/login/begin", { auth: "public" }, async (request) => { const body = await request.json() return await appAuth.passkey.beginAuthentication({ email: body.email, })})const begin = await fetch("/auth/passkeys/login/begin", { method: "POST", body: JSON.stringify({ email: "ada@example.com" }),}).then((response) => response.json())
const credential = await navigator.credentials.get({ publicKey: { ...begin.publicKey, challenge: fromBase64url(begin.publicKey.challenge), allowCredentials: begin.publicKey.allowCredentials.map((entry) => ({ ...entry, id: fromBase64url(entry.id), })), },})Finish authentication to create an Auth session.
app.post("/auth/passkeys/login/finish", { auth: "public" }, async (request) => { const body = await request.json() return await appAuth.passkey.finishAuthentication({ ceremonyId: body.ceremonyId, credential: body.credential, })})const result = await fetch("/auth/passkeys/login/finish", { method: "POST", body: JSON.stringify({ ceremonyId: begin.ceremonyId, credential: credentialForAuth(credential as PublicKeyCredential), }),}).then((response) => response.json())
console.log(result.accessToken)Auth verifies the assertion signature against the stored public key, checks the challenge and origin, validates the sign counter when the authenticator reports one, updates the stored credential usage fields, and creates the session.
MFA and Step-Up
Section titled “MFA and Step-Up”Use passkey authentication with purpose: "mfa" or purpose: "step_up" from a
route that already has an active session.
app.post("/auth/passkeys/step-up/begin", { auth: "user" }, async () => { return await appAuth.passkey.beginAuthentication({ purpose: "step_up", })})
app.post("/auth/passkeys/step-up/finish", { auth: "user" }, async (request) => { const body = await request.json() return await appAuth.passkey.finishAuthentication({ ceremonyId: body.ceremonyId, credential: body.credential, stepUpPurpose: "billing_settings", })})When passkey.mfa.requireUserVerification is true, Auth requires the
WebAuthn UV flag for MFA and step-up ceremonies.
Manage Passkeys
Section titled “Manage Passkeys”List, rename, and remove passkeys from signed-in routes.
app.get("/auth/passkeys", { auth: "user" }, async () => { return await appAuth.passkey.list()})
app.post("/auth/passkeys/rename", { auth: "user" }, async (request) => { const body = await request.json() return await appAuth.passkey.rename({ passkeyId: body.passkeyId, friendlyName: body.friendlyName, })})
app.post("/auth/passkeys/remove", { auth: "user" }, async (request) => { const body = await request.json() return await appAuth.passkey.remove({ passkeyId: body.passkeyId, })})Removing a passkey disables the stored credential and keeps the historical row for session and security analysis.