Skip to content

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.

Configure the relying party id and allowed browser origins.

Terminal window
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.

Browsers return WebAuthn binary fields as ArrayBuffer. Auth accepts those fields as base64url strings in JSON.

Terminal window
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,
},
}
}

Registration requires an active Auth session. A route can call appAuth.passkey.beginRegistration() and return the WebAuthn options to the browser.

Terminal window
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.

Terminal window
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.

Terminal window
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,
})
})
Terminal window
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.

Start login by email, by user id, or with a usernameless ceremony when allowUsernameless is enabled.

Terminal window
app.post("/auth/passkeys/login/begin", { auth: "public" }, async (request) => {
const body = await request.json()
return await appAuth.passkey.beginAuthentication({
email: body.email,
})
})
Terminal window
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.

Terminal window
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,
})
})
Terminal window
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.

Use passkey authentication with purpose: "mfa" or purpose: "step_up" from a route that already has an active session.

Terminal window
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.

List, rename, and remove passkeys from signed-in routes.

Terminal window
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.