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.