RSSAmplifier

Sentinel Den · Engineering blog · Apr 27, 2026

Step-up auth on risk, not fat-finger taps: hysteretic transitions

0
Sign in to vote or save

Muhammad Khan · Sentinel Den

Behavioral risk scores oscillate. They oscillate because users oscillate: hand position changes, attention drifts, network conditions affect input timing, a phone call interrupts a typing flow. A risk score that responds instantly to every oscillation will trigger step-up authentication on noise, training the user to mash through prompts and defeating the purpose.

BehaviorGuard’s risk engine uses a hysteretic state machine: risk-band transitions require not just a threshold crossing but a sustained crossing for a configurable dwell time. This post is the engineering motivation, the math, and the API.

Why naive thresholds produce auth fatigue

The first-cut implementation: score the user’s input session every few seconds, classify into four bands (safe, low, medium, high), and on every band transition, fire the policy bound to the new band.

// Naive: instantaneous transitions
func onRiskScore(_ score: Double) {
    let newBand = bandFor(score)
    if newBand != currentBand {
        currentBand = newBand
        policy.onTransition(to: newBand)
    }
}

func bandFor(_ score: Double) -> RiskBand {
    switch score {
    case ..<0.25:  return .safe
    case ..<0.50:  return .low
    case ..<0.75:  return .medium
    default:       return .high
    }
}

What happens in practice: a user typing in a wallet’s “send amount” field has their input briefly affected by something, they switched from typing with their thumb to typing with their index finger, they paused to check the price, the phone vibrated from a notification and shifted their grip. The behavioral score blips from 0.18 (safe) to 0.52 (medium) for two scoring windows, then settles back to 0.20. The naive engine fires policy.onTransition(to: .medium), which in a wallet app means step-up authentication required. The user gets a Face ID prompt mid-typing, completes it, and resumes. Three transactions later, it happens again. Six transactions later, the user is mashing through Face ID prompts without reading what they’re authenticating.

The user has been trained that the prompts are noise. Now an actual session-takeover prompt arrives, and they mash through it.

Hysteresis: the gap between trip and reset

The fix borrowed from analog electronics: separate the trip threshold from the reset threshold. To enter medium from low, the score must cross 0.50 and stay there for the dwell window. To leave medium back to low, the score must fall to 0.40 and stay there for the dwell window. The gap (0.10 in this example) is the hysteresis band, a brief blip into medium that returns to 0.20 within the dwell window doesn’t trigger.

public struct BandThresholds {
    public let lowEnter: Double = 0.25, lowExit: Double = 0.20
    public let medEnter: Double = 0.50, medExit: Double = 0.40
    public let highEnter: Double = 0.75, highExit: Double = 0.65

    public let dwellSeconds: TimeInterval = 4.0
}

The state machine becomes:

public final class HystereticRiskEngine {
    private var currentBand: RiskBand = .safe
    private var pendingBand: RiskBand?
    private var pendingSince: Date?
    private let thresholds: BandThresholds

    public func onScore(_ score: Double, now: Date = .init()) {
        let candidate = bandForCandidate(score: score, current: currentBand)
        if candidate == currentBand {
            // No transition pending, clear any candidate state.
            pendingBand = nil
            pendingSince = nil
            return
        }
        if pendingBand != candidate {
            // New candidate, start the dwell timer.
            pendingBand = candidate
            pendingSince = now
            return
        }
        // Same candidate as before, has the dwell elapsed?
        if let since = pendingSince,
           now.timeIntervalSince(since) >= thresholds.dwellSeconds {
            commitTransition(to: candidate)
        }
    }

    private func bandForCandidate(score: Double, current: RiskBand) -> RiskBand {
        // Use enter or exit thresholds depending on direction of motion.
        switch current {
        case .safe:
            if score >= thresholds.highEnter { return .high }
            if score >= thresholds.medEnter  { return .medium }
            if score >= thresholds.lowEnter  { return .low }
            return .safe
        case .low:
            if score >= thresholds.highEnter { return .high }
            if score >= thresholds.medEnter  { return .medium }
            if score < thresholds.lowExit    { return .safe }
            return .low
        case .medium:
            if score >= thresholds.highEnter { return .high }
            if score < thresholds.medExit    { return .low }
            return .medium
        case .high:
            if score < thresholds.highExit   { return .medium }
            return .high
        }
    }

    private func commitTransition(to band: RiskBand) {
        currentBand = band
        pendingBand = nil
        pendingSince = nil
        emit(.bandTransition(to: band))
    }
}

A score blip from 0.18 → 0.52 → 0.20 in two seconds: candidate becomes .medium at the spike, dwell timer starts, then candidate goes back to .safe and the pending state clears. No transition is committed. No Face ID prompt fires.

A sustained climb from 0.18 → 0.52 → 0.55 → 0.58 over 6 seconds: candidate is .medium, dwell elapses, transition commits, step-up fires once. The user gets a single, justified prompt.

Choosing the dwell window

The dwell window is the central tuning knob. Too short and you’re back to firing on blips. Too long and the engine is sluggish to respond to a real session-takeover, the class of attack detailed in the BehaviorGuard threat model.

The BehaviorGuard team’s empirical defaults from production tuning across fintech and crypto-wallet apps:

  • safe → low transitions: 3 seconds. Low-stakes; only affects UI density.
  • low → medium transitions: 4 seconds. Medium triggers degraded permissions (read-only, no send).
  • medium → high transitions: 5 seconds. High triggers session lock + Face ID re-auth.
  • Downward transitions (high → medium, etc.): 10 seconds. Slower descent is intentional, we want the elevated state to persist until we have confidence the anomaly resolved.

The slower downward dwell is the hysteresis acting in the other direction: once we’ve decided this might be a session-takeover, we keep elevated protections in place even after the score drops, until the score sustains a low value. This is the structurally correct asymmetry, failing closed on risk.

What this looks like in the audit log

Every commit transitions and every cleared-pending-state both emit audit events. The chain reads:

2026-04-22 14:32:01  SCORE  0.18  SAFE
2026-04-22 14:32:05  SCORE  0.52  PENDING_MEDIUM_FROM_LOW   (dwell 0s/4s)
2026-04-22 14:32:09  SCORE  0.51  PENDING_MEDIUM_FROM_LOW   (dwell 4s/4s)
2026-04-22 14:32:09  TRANSITION  LOW → MEDIUM
2026-04-22 14:32:09  STEP_UP_REQUESTED  reason=med_band
2026-04-22 14:32:14  BIOMETRIC_OK
2026-04-22 14:32:14  STEP_UP_SATISFIED
2026-04-22 14:34:08  SCORE  0.38  PENDING_LOW_FROM_MEDIUM   (dwell 0s/10s)
2026-04-22 14:34:18  SCORE  0.36  PENDING_LOW_FROM_MEDIUM   (dwell 10s/10s)
2026-04-22 14:34:18  TRANSITION  MEDIUM → LOW

The audit consumer can compute the dwell timing, the score trajectory, and the user response. A pattern of frequent PENDING_… events that don’t commit is a useful signal that the user is in a noisy environment (commute, train, walking) and the policy might want to widen the hysteresis band rather than firing more often.

Configuring per-context bands

A fintech app’s “transfer money” view and “view balance” view should not have the same risk thresholds. Transfer should require lower medEnter (more conservative, even a small risk elevation triggers step-up); view-balance can tolerate a higher medEnter. BehaviorGuard’s risk engine accepts per-context overrides:

final class BehaviorGuard {
    func setRiskContext(_ ctx: RiskContext) {
        engine.applyContext(ctx)
    }
}

// In the transfer screen:
behaviorGuard.setRiskContext(.transfer)  // medEnter = 0.40, dwell = 3s

// In the balance screen:
behaviorGuard.setRiskContext(.balance)   // medEnter = 0.60, dwell = 5s

// In an idle / chat screen:
behaviorGuard.setRiskContext(.casual)    // medEnter = 0.75, dwell = 8s

The per-context thresholds are config, not code, they live in BehaviorGuardConfiguration and can be tuned without rebuilding the SDK.

The friction / security trade-off, made measurable

Hysteretic transitions don’t eliminate step-up prompts. They eliminate the wrong prompts. The BehaviorGuard team’s measurement from a fintech production deployment (2026 Q1):

  • Naive thresholds: 4.7 step-up prompts per active user per day. 83% of users mashed through within 2 seconds of prompt appearing (mean dwell on prompt: 1.4s).
  • Hysteretic with 4s dwell: 0.9 step-up prompts per active user per day. 31% of users mashed through within 2 seconds; 69% read the prompt (mean dwell: 4.2s).

The 5× reduction in prompts produced a 3× reduction in fatigue-mashing, but it also produced a higher user-attention rate per prompt. Step-up auth becomes a meaningful signal again because the prompts are rare and tied to real risk.

This is the operational outcome the hysteretic engine targets: fewer prompts, better-quality prompts, more attention per prompt. That is what makes step-up a defense rather than a UX tax.

See /sdk/behaviorguard for the marketing summary, /docs/behaviorguard for the risk-engine configuration reference, and the companion posts on continuous authentication after Face ID, ten behavioral signals, and PSI drift detection.

Read the original on sentinelden.com

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.