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:

  1. 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, Blocked or CaptchaRequired. 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 loud abuse.counter_store_unavailable event by default.
  2. User lookup — a miss burns a dummy argon2id verification so timing doesn’t distinguish “unknown user” from “wrong password”.
  3. Credential verification — with transparent rehash-on-login.
  4. Risk gate — deterministic IRiskSignals (new device 30, impossible travel 40, IP reputation 50, velocity 25) sum into a score; thresholds map to Allow / StepUpMfa (≥ 40) / Block (≥ 80). Every decision is explainable: the contributing signals land on the risk.evaluated security event. An external risk-score provider port composes with the built-ins.
  5. 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 registration
  • GET|POST /auth/passkey/login/options and POST /auth/passkey/login — passwordless login
  • POST /auth/passkey/mfa/verify — passkey as the second factor
  • GET /auth/passkey/ and DELETE /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_eligible rather than quietly demoting security.
  • Sign-count regression (a cloned authenticator’s tell) fails the login generically on the wire and emits passkey.signcount_regression for your SOC.
  • A successful passkey login marks the session SessionMfaLevel.PhishingResistant, which flows into the access token’s mfa claim — 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.

Learn by building

The tutorials for this area, in order — each with a runnable sample.