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.