Articles · Authentication & Factors
Risk, scored in the open
Try out the running example
The abuse gate from article 013 counts attempts; it has no opinion about this attempt. The risk engine is that opinion: the password was correct, the counters are quiet — should this login still complete on one factor? Vendors sell this as a black box with an ML sticker on it. Sentinel’s version is the opposite on purpose: a handful of deterministic, additive signals, each returning a bounded score and a human-readable reason, summed against two thresholds you can read in the options. The same context always scores the same. When an analyst asks “why was the CFO stepped up at 9:03”, the answer is in the audit row, verbatim.
The signal contract
public interface IRiskSignal
{
string Name { get; }
ValueTask<RiskContribution> AssessAsync(RiskContext context, CancellationToken cancellationToken = default);
}
A RiskContribution is (Score, Reason, Signal). A signal that can’t decide — missing
fact, no resolver — contributes zero rather than guessing; a signal that throws is
treated as zero and surfaced as a risk.signal_error event, so one broken geo provider
never blocks logins. AddSentinel() registers four built-ins:
| Signal | Score | Fires when |
|---|---|---|
new_device |
30 | the presented device fingerprint was never seen for this user |
impossible_travel |
40 | geo distance since last login implies > 1000 km/h |
ip_reputation |
50 | the address is on the host’s denylist |
velocity |
25 | too many distinct IPs for this account within an hour |
The scores are policy, not magic — chosen so that no single “hm” blocks, reputation
alone forces a second factor, and combinations escalate. Against the default
SentinelRiskOptions thresholds — step-up at 40, block at 80 — one glance gives you
the whole matrix: a new device (30) sails with an alert; a listed IP (50) demands MFA; a
listed IP on a new device (80) is refused outright.
The built-ins are armed by ports the host plugs in — IIpReputationProvider,
IGeoResolver, IDeviceHistoryStore — each defaulting to an inert noop, because
Sentinel ships no IP lists and no geo database. The sample arms reputation with six
lines:
// file: StepUpWorld.cs
public sealed class DemoIpReputation : IIpReputationProvider
{
public ValueTask<bool> IsListedAsync(string ip, CancellationToken cancellationToken = default) =>
ValueTask.FromResult(ip == StepUpWorld.BadIp);
}
Your own signal is ten lines
Custom signals join the same evaluation — TryAddEnumerable alongside the built-ins,
no interface beyond the one above. The sample adds an application-level judgment the
library could never know:
// file: StepUpWorld.cs
public sealed class WatchlistSignal : IRiskSignal
{
public const int Weight = 40;
public string Name => "watchlist";
public ValueTask<RiskContribution> AssessAsync(
RiskContext context, CancellationToken cancellationToken = default) =>
ValueTask.FromResult(StepUpWorld.Watchlist.Contains(context.UserId)
? new RiskContribution(Weight, "subject is on the fraud watchlist", Name)
: RiskContribution.None(Name, "not watchlisted"));
}
The_custom_watchlist_signal_steps_victor_up_from_anywhere logs Victor in from a clean
IP on no particular device — and he’s stepped up anyway, with "subject is on the fraud watchlist" sitting verbatim in the risk.evaluated row on the security ledger. Every
login writes that row, allowed ones included: contributions, total, decision. The
explainability is the audit trail.
Step-up, and the fallback that makes it honest
A score in the step-up band turns a correct password into an unfinished login:
{ "status": "mfa_required", "factor": "email_otp", "mfaPendingToken": "…" }
No tokens yet — the pending token is single-purpose (typ: mfa+sentinel, the same
discipline as article 001). A user with TOTP enrolled answers
with their authenticator code. But risk-driven step-up has a problem enrollment-driven
MFA doesn’t: it targets exactly the users who never enrolled. Punting there would mean
either letting the risky login through or locking the user out of their own account —
so Sentinel falls back to a one-time code mailed to the verified email, and the
response’s factor field says which flavor is in play.
// A_listed_ip_forces_step_up_and_email_otp_completes_it
var mail = host.Mailer.Sent.Should()
.ContainSingle(m => m.Kind == SentinelMailKinds.EmailOtp).Subject;
// A wrong code does not complete the login…
(await host.VerifyEmailOtpAsync(pending, "000000")).StatusCode
.Should().Be(HttpStatusCode.Unauthorized);
// …the mailed code does.
var verify = await host.VerifyEmailOtpAsync(pending, mail.Data["code"]);
The completion endpoint is the ordinary /auth/mfa/verify with "kind": "email_otp" —
one step-up ceremony, two factors that can satisfy it. The mailer is a port
(ISentinelMailer); the sample’s records mails so tests read the code, and the noop
default means a host that never registered one effectively can’t use the fallback —
codes that go nowhere, by documented design rather than silent success.
The block, and what it refuses to say
At 80 the engine stops negotiating — and says nothing:
// Stacked_signals_block_indistinguishably_from_a_wrong_password
blocked.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
(await blocked.Content.ReadAsStringAsync())
.Should().Be(await wrongPassword.Content.ReadAsStringAsync())
.And.Contain("invalid_credentials");
Byte-identical to a wrong password. A distinct “your risk score is too high” response
would hand an attacker with valid stolen credentials an oracle for tuning their approach
— rotate IPs until the error changes. The distinction lives where it belongs: a
risk.blocked event in the audit stream, which the
webhook pipeline can page on.
The 30-point case: annoy nobody, tell everybody
A new device alone doesn’t step up — 30 < 40, and punishing every browser reinstall
with MFA teaches users that security prompts are noise. It does something quieter:
completes the login, records the fingerprint, emits login.new_device, and sends a
security_alert mail — “new sign-in from a device we haven’t seen”. The one alert the
victim of a credential theft will actually read, on the one channel the attacker doesn’t
control. A_new_device_alerts_by_mail_but_30_points_do_not_step_up pins the mail, the
event, and that the same fingerprint the next day alerts nobody.
The fingerprint is client-supplied (deviceFingerprint on the login request), which is
worth being honest about: an attacker can replay a stolen fingerprint. That’s why
new-device is 30 points and a signal, not an allowlist — it degrades the attacker’s
position when combined with anything else, and costs nothing when spoofed alone.
Where the sample uses a listed IP and a watchlist, production tenants tune the same two
numbers in SentinelRiskOptions and arm the same ports. The
authentication page places the risk gate in the full login
pipeline — after the abuse gate, after credential verification, before any token is
minted.