Capabilities
Authentication & factors
Passkeys-first authentication for .NET — password with argon2id, WebAuthn as first or second factor, TOTP, email OTP, recovery codes, refresh-token families, risk-based step-up and four layers of abuse protection.
Sentinel’s authentication stack is passkeys-first: WebAuthn works as a passwordless first factor or as a second factor, and the rest of the factor set — password, TOTP, email OTP, recovery codes — is built so the phishing-resistant path is always the easy path.
Factors
| Factor | Implementation |
|---|---|
| Password | argon2id (64 MiB, 3 iterations) via Argon2idPasswordHashAlgorithm; PBKDF2-SHA256 kept as a tagged legacy algorithm |
| Passkeys / WebAuthn | PasskeyService over Fido2NetLib — first factor (user-verifying credentials) or second factor |
| TOTP | RFC 6238, 6 digits / 30 s step, ±1 step drift, per-enrollment replay protection |
| Email OTP | EmailOtpService, used both as an enrolled factor and as the risk-engine’s step-up fallback |
| Recovery codes | Hashed at rest (RecoveryCodes.HashForStorage), single-use, regenerable |
Every credential row is tagged with its algorithm, and
PasswordHasher.VerifyAndUpgrade(...) transparently rehashes to the current default on
successful login — which is also how imported bcrypt/PBKDF2 hashes from
migrations age out of your database.
The login pipeline
LoginService.LoginWithPasswordAsync runs a fixed gauntlet, in order:
- Abuse gate — four windowed layers (per-IP 30/5 min, per-IP-and-account 10/15 min,
account lockout 5/15 min, credential-stuffing heuristic 200 distinct identifiers per
IP/10 min) decide
Allowed,BlockedorCaptchaRequired. Adaptive CAPTCHA (Turnstile/hCaptcha/reCAPTCHA adapters) engages under pressure rather than punishing everyone always. The failure mode when the counter store is down is configurable per layer — fail-open with a loudabuse.counter_store_unavailableevent by default. - User lookup — a miss burns a dummy argon2id verification so timing doesn’t distinguish “unknown user” from “wrong password”.
- Credential verification — with transparent rehash-on-login.
- Risk gate — deterministic
IRiskSignals (new device 30, impossible travel 40, IP reputation 50, velocity 25) sum into a score; thresholds map toAllow/StepUpMfa(≥ 40) /Block(≥ 80). Every decision is explainable: the contributing signals land on therisk.evaluatedsecurity event. An external risk-score provider port composes with the built-ins. - MFA step-up — TOTP if enrolled; otherwise email OTP when risk demands step-up.
The MFA hand-off is a single-purpose signed token, not server state: a JWT with
typ: "mfa+sentinel" and a 5-minute lifetime carries the pending login (subject, realm,
requested org, audience, IP) to POST /auth/mfa/verify. Type confusion is impossible —
an mfa+sentinel token is worthless at any endpoint expecting at+sentinel, because
typ is always enforced.
Testing note: TOTP anti-replay. A TOTP code is single-use per 30-second step: after one successful verify, the enrollment’s accepted step only moves forward, so presenting the same (or an older) code again fails — even though the raw code is still inside the ±1-step drift window. Integration tests that log the same TOTP-enrolled user in twice must either wait for the next step, compute the next step’s code, or use a recovery code / fresh enrollment for the second login. A bare “reuse the code from the last test” fails by design, not by flakiness.
Passkeys
The WebAuthn ceremonies mount under /auth/passkey:
POST /auth/passkey/register/options→{ ceremonyId, options }(authenticated)POST /auth/passkey/register— completes registrationGET|POST /auth/passkey/login/optionsandPOST /auth/passkey/login— passwordless loginPOST /auth/passkey/mfa/verify— passkey as the second factorGET /auth/passkey/andDELETE /auth/passkey/{credentialId}— credential management
Configuration is explicit — AddSentinelPasskeys requires the relying-party identity:
builder.Services.AddSentinelPasskeys(o =>
{
o.RpId = "example.com";
o.RpName = "Example";
o.Origins.Add("https://app.example.com");
});
Design points worth knowing before you ship them:
- Stateless-ish ceremonies: only a SHA-256 of the issued challenge is stored, under a random ceremony id with a 5-minute TTL and a single attempt.
- First-factor eligibility is earned: a passkey signs you in alone only when the
credential is UV-capable and user verification actually happened in the assertion;
otherwise the endpoint returns 401
passkey_not_eligiblerather than quietly demoting security. - Sign-count regression (a cloned authenticator’s tell) fails the login generically
on the wire and emits
passkey.signcount_regressionfor your SOC. - A successful passkey login marks the session
SessionMfaLevel.PhishingResistant, which flows into the access token’smfaclaim — your policies can require it.
Article 004 — Passkeys builds the full browser round trip.
Tokens and sessions
Access tokens are RS256 JWTs (10-minute lifetime by default) with claims
iss, aud, sub, realm, org?, sid, mfa, iat, exp, jti, verified statelessly via JWKS with
cached keys. Refresh tokens are opaque srt_ values (30 days), stored only as SHA-256
hashes, rotating on every use with family-based reuse detection: replaying a rotated
token revokes the whole family and emits token.refresh_reuse_detected. See
article 003 for the mechanism.
Sessions are realm-level and app-agnostic; access tokens are app-scoped via aud.
Session records power GET /profile/sessions (device listing),
DELETE /profile/sessions/{id} (remote logout — with ownership checks that 404
identically for foreign and nonexistent sessions), idle/absolute timeouts, and
POST /auth/logout-all.
Both transports are first-class: Bearer for APIs and machines, httpOnly cookies with
double-submit CSRF (sentinel_csrf cookie + X-Sentinel-Csrf header) for browsers —
cookie-first is the guidance for web apps. Cookie paths respect the host’s PathBase,
and the refresh cookie is path-scoped to the refresh endpoint only.
Flows
Beyond login: forgot/reset password and email verification ship as PasswordResetService
(single-use tokens with typ values pwreset+sentinel / everify+sentinel, JTI
deny-listed on use; a reset revokes every refresh token the subject holds), invitation
tokens (invite+sentinel), org switch (mint a new token for another organization without
re-authentication), and logout / logout-all. Reset requests always report success —
enumeration yields nothing.
Related security recipes
- Credential stuffing — the four abuse layers, in anger.
- User enumeration — dummy hashing and uniform responses.
- Session fixation — why sessions are minted only by Sentinel, post-authentication.
Learn by building
The tutorials for this area, in order — each with a runnable sample.