Radon
Auth Methods

Passkeys / WebAuthn

Phishing-resistant biometric sign-in with passkeys. Radon runs the server-side ceremonies; your frontend calls the browser WebAuthn APIs. A Radon Pro feature.

Radon Pro

Passkeys are a Pro feature and require a license. They also need the @simplewebauthn/server package on the server. See Radon Pro.

Passkeys let users sign in with Face ID, Touch ID, Windows Hello, or a security key — no password, and phishing-resistant because the credential is bound to your domain. Radon uses the audited @simplewebauthn/server library server-side; it never hand-rolls attestation parsing.

How passkeys are split between server and browser

WebAuthn needs browser APIs Radon can't call from the server. So every ceremony is two round-trips:

  1. Radon produces options (a challenge) → you send them to the browser.
  2. The browser calls navigator.credentials.create() / .get() (or @simplewebauthn/browser) → you POST the result back to Radon to verify.

You must persist the challenge

Each start* call returns a challenge. Store it between the two round-trips (a short-lived session/cookie) and pass it back to the matching finish* call. Radon hands it to you rather than assuming where you keep state.

1. Enable the provider

npm install @simplewebauthn/server   # server
npm install @simplewebauthn/browser  # client (in your frontend app)
lib/auth.ts
export const auth = new Radon({
  adapter: postgresAdapter(pool),
  session: { secret: process.env.RADON_SECRET! },
  licenseKey: process.env.RADON_LICENSE_KEY,
  appName: "Acme",
  providers: {
    webauthn: {
      rpID: "acme.com",               // your registrable domain (no scheme/port)
      origin: "https://acme.com",     // full origin(s); string or string[]
    },
  },
});

await auth.init();
OptionTypeDescription
rpIDstringRequired. Registrable domain, e.g. acme.com.
originstring | string[]Required. Full origin(s), e.g. https://acme.com.
rpNamestringDisplay name in the prompt. Defaults to appName.

2. Registration (enroll a passkey)

The user must already be signed in (by any method) — passkeys attach to an existing user.

Server — start
// POST /passkeys/register/start  (your own route)
const { options, challenge } = await auth.webauthn.startRegistration(userId);
// Store `challenge` in the session, send `options` to the browser.
return Response.json({ options, challenge });
Browser
import { startRegistration } from "@simplewebauthn/browser";

const { options, challenge } = await fetch("/passkeys/register/start", { method: "POST" }).then(r => r.json());
const response = await startRegistration(options); // prompts Face ID / security key
await fetch("/passkeys/register/finish", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ response, challenge }),
});
Server — finish
// POST /passkeys/register/finish
const { credential } = await auth.webauthn.finishRegistration({
  userId,
  response,                       // the browser's result
  expectedChallenge: challenge,   // the one you stored
});
// Passkey stored. Done.

3. Authentication (sign in with a passkey)

Server — start
const { options, challenge } = await auth.webauthn.startAuthentication(userId);
// `userId` is optional — omit it for usernameless / discoverable-credential login.
return Response.json({ options, challenge });
Browser
import { startAuthentication } from "@simplewebauthn/browser";

const { options, challenge } = await fetch("/passkeys/login/start", { method: "POST" }).then(r => r.json());
const response = await startAuthentication(options);
await fetch("/passkeys/login/finish", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ response, challenge }),
});
Server — finish
const { user } = await auth.webauthn.finishAuthentication({
  userId,                         // optional for usernameless login
  response,
  expectedChallenge: challenge,
});
const { token } = auth.createSessionToken(user.id); // sign them in

Managing passkeys

const creds = await auth.webauthn.listCredentials(userId); // no key material exposed
await auth.webauthn.removeCredential(userId, credentialId);

Next steps

On this page