Articles · Authentication & Factors

Passkeys, first-class

Try out the running example

Passwords get phished; passkeys don’t. Sentinel’s positioning is passkeys-first: the WebAuthn path is a first factor in its own right, not a bolt-on second step. This article wires the ceremonies end to end and covers the three design rules that keep the convenience from costing you the security.

Setup

Passkeys need to know who the relying party is — there are no guessable defaults:

builder.Services.AddSentinelPasskeys(o =>
{
    o.RpId   = "example.com";                    // the credential's scope
    o.RpName = "Example Clinic";
    o.Origins.Add("https://app.example.com");    // exact origins that may complete ceremonies
    // o.CeremonyLifetime defaults to 5 minutes
});

app.MapSentinelPasskeys();   // mounts under /auth/passkey

The endpoint group:

Route Purpose
POST /auth/passkey/register/options begin registration → { ceremonyId, options }
POST /auth/passkey/register complete registration
GET/POST /auth/passkey/login/options begin passwordless login
POST /auth/passkey/login complete passwordless login
POST /auth/passkey/mfa/verify passkey as second factor for a pending MFA login
GET /auth/passkey/ list credentials
DELETE /auth/passkey/{credentialId} remove a credential

† requires an authenticated principal.

Registration

The dance is options → browser → completion:

// 1. Ask Sentinel for creation options (authenticated call).
const { ceremonyId, options } = await api.post('/auth/passkey/register/options');

// 2. Hand them to the platform authenticator.
const credential = await navigator.credentials.create({ publicKey: decode(options) });

// 3. Return the attestation with the ceremony id.
const registered = await api.post('/auth/passkey/register', {
  ceremonyId,
  label: 'MacBook Touch ID',
  response: encode(credential),
});
// → { status: "ok", id, credentialId, uvCapable, label }

Sentinel’s defaults are the sensible modern ones: resident key preferred, user verification preferred, attestation none (attestation CA theater buys little and costs privacy), and already-registered credentials excluded so users don’t double-enroll a device.

Under the hood the ceremony is nearly stateless: Sentinel stores only a SHA-256 of the challenge under a random ceremony id, single-attempt, 5-minute TTL. Completion reads the challenge back out of the browser’s clientDataJSON and verifies the hash — there’s no server-side options blob to leak or replay, and a ceremony id is useless twice.

Passwordless login

const { ceremonyId, options } = await api.get('/auth/passkey/login/options');
const assertion = await navigator.credentials.get({ publicKey: decode(options) });
const tokens = await api.post('/auth/passkey/login', {
  ceremonyId,
  response: encode(assertion),
});

Here is rule one: first factor status is earned, not assumed. The login succeeds alone only if the credential is UV-capable and the authenticator actually performed user verification in this assertion. A silent, no-UV assertion from a roaming key gets 401 passkey_not_eligible — Sentinel won’t let a possession-only proof impersonate a two-factor login.

Rule two: a successful passkey login marks the session SessionMfaLevel.PhishingResistant, which mints into the access token as "mfa": "phishing_resistant". That claim is policy fuel — an admin surface or a payments endpoint can demand phishing-resistant sessions and step everyone else up.

The clone alarm

WebAuthn authenticators keep a signature counter. If an assertion arrives whose count has gone backwards, someone is signing with a copy of the credential. Sentinel’s handling (rule three): the login fails with the same generic 401 as any bad assertion — the attacker learns nothing — while passkey.signcount_regression goes to the event sink and webhooks with the credential id attached. Treat it like token.refresh_reuse_detected: rare, high-signal, page-worthy.

Passkey as second factor

When password login answers "status": "mfa_required", the pending token works with the passkey ceremony too:

const { ceremonyId, options } = await api.post('/auth/passkey/login/options');
const assertion = await navigator.credentials.get({ publicKey: decode(options) });
await api.post('/auth/passkey/mfa/verify', {
  mfaPendingToken,          // from the password login response
  ceremonyId,
  response: encode(assertion),
});

The pending token is a signed single-purpose JWT (typ: "mfa+sentinel", 5 minutes) — no server-side pending-login state, and structurally useless anywhere else.

Credential management

GET /auth/passkey/ returns each credential’s id, label, UV capability, creation and last-used timestamps — enough to render the standard “your passkeys” settings page. Deletion emits passkey.removed; registration emitted passkey.registered. Both are user-visible security events, so the activity feed shows a passkey appearing the moment an attacker with a hijacked session tries to persist their access — which is exactly the scenario where you want re-authentication before sensitive actions.

What the host still owns

Sentinel runs the ceremonies; you own the UX that makes passkeys stick: offering registration at the right moment (right after login, not during signup friction), labeling credentials sensibly, and deciding which operations demand mfa: "phishing_resistant". The authentication reference covers the surrounding factor stack, and the session fixation recipe explains why all of this only mints sessions server-side, after verification.