A continuous-authentication SDK depends on a baseline. The user enrols (consciously or implicitly), the SDK observes a few hundred samples, and from then on the SDK has a reference distribution over a feature space. Every new sample is compared against the reference. If the new samples match the reference, the SDK emits “this is the user”. If they diverge, the SDK emits “this might not be the user”.
That works until the reference itself becomes stale. The user breaks their wrist and types differently for six weeks. The user starts using the phone in landscape because they got a new keyboard case. The user is travelling, holds the phone with a different posture, walks with a different gait because they’re tired. The user starts using their non-dominant hand for one-handed scrolling on the train. Each of these is a legitimate behavioral change. The SDK does not know it is legitimate. The SDK sees the new samples diverging from the reference and reports “this might not be the user”, over and over, until either the SDK locks the session or the noise causes the integrator to disable the SDK entirely.
The right answer is baseline drift detection: a separate signal that says “the user’s typical behavior has changed enough that the reference should be updated”, distinct from “this individual sample is suspicious”. BehaviorGuard uses the Population Stability Index (PSI), a metric from credit-risk modeling, to measure this. This post is the explanation: what PSI is, why it’s the right metric for behavioral baselines on iOS, how the integration emits drift events, and what to do when one fires.
The metric: PSI defined
Population Stability Index is a number that quantifies how much one distribution has shifted relative to another. Given a reference distribution R and a new distribution N, both over the same set of bins, PSI is:
PSI = Σ_i (N_i - R_i) × ln(N_i / R_i)
where N_i and R_i are the probability masses in bin i of the new and reference distributions, respectively. The metric is symmetric (PSI(N, R) == PSI(R, N)), bounded below by 0 (identical distributions), and has standard thresholds from the credit-risk literature:
PSI < 0.10, no meaningful drift. Continue with the existing baseline.0.10 ≤ PSI < 0.20, small drift. Worth noting. Likely temporary.PSI ≥ 0.20, meaningful drift. The reference distribution materially no longer represents the user. Re-enrollment is appropriate.PSI ≥ 0.25, significant drift. The reference is stale enough that continuing to use it produces unreliable signal.
BehaviorGuard’s default driftPSIThreshold is 0.20, the industry-standard “meaningful drift” cutoff. The threshold is configurable.
Why PSI is the right choice for behavioral biometrics
Three properties of PSI make it the right metric here, none of which are obvious until you’ve tried the alternatives:
1. PSI is sensitive to shape changes, not just mean shifts. If the user starts typing slightly faster overall (the mean of their typing-cadence distribution shifts), a simple mean-shift detector fires. But that’s not interesting, the user is still typing, just faster. PSI ignores pure mean shifts in favor of distributional changes. A user whose typing distribution becomes bimodal (two-handed vs one-handed) shows up in PSI; the same user typing faster does not.
2. PSI is interpretable. A PSI of 0.18 versus a PSI of 0.42 are different magnitudes with a clear semantic: the credit-risk world has used these thresholds for decades, the operations team integrating BehaviorGuard immediately understands what “PSI 0.22, re-enroll” means. Alternatives like KL divergence are equally principled but operationally harder to communicate.
3. PSI is cheap to compute on a phone. The metric requires a histogram of the new samples (which BehaviorGuard maintains as a rolling window) and the reference distribution (stored once at enrollment). The computation is O(bins), a histogram pass and a sum, cheap enough to run on any supported device without a measurable cost. BehaviorGuard re-evaluates PSI once per minute by default; the cost is negligible.
What PSI does not catch
PSI is a distributional metric. Two failure modes it cannot detect on its own:
-
The user behaves identically to their own baseline but it’s the wrong user. If an attacker happens to have a similar distribution over the features PSI tracks, PSI is happy. This is exactly the impersonation case enumerated in the BehaviorGuard threat model, where the per-sample risk signal is the fallback that fires, and the architecture pairs PSI (a slow signal of “is the baseline still valid”) with the per-sample anomaly detector (a fast signal of “is this sample suspicious”). Neither replaces the other.
-
Slow drift over time. If the user’s behavior drifts by 5% per month for two years, no individual PSI computation crosses the 0.20 threshold. The cumulative drift is real but invisible. BehaviorGuard’s mitigation is a cumulative drift accumulator: a slow exponential moving average of PSI over months that fires re-enrollment when the cumulative drift exceeds a separate threshold (configurable, off by default, opt-in for long-lived sessions).
The architectural takeaway: PSI is the right signal for “is the baseline stale”, but it should never be the only signal. The full BehaviorGuard signal stack is: per-sample anomaly score → per-band risk transition → PSI drift evaluation, each emitting independently into the event stream.
What happens when drift fires
When PSI crosses the threshold, BehaviorGuard emits a .baselineDriftDetected(DriftReport) event. The report contains:
struct DriftReport: Sendable {
let psi: Double // 0.22, 0.34, etc
let confidence: Confidence // .meaningful / .significant
let driftedSensors: Set<SensorType> // which collectors contributed
let evaluatedAt: Date
let sampleCountSinceEnrollment: Int
}
The SDK does not take action. The integrator decides what action to take. Three common patterns:
Pattern A, Silent re-enrollment. Most apps. On .baselineDriftDetected, schedule a re-enrollment session at the next opportune moment (after the user completes the current task, on next app launch, or after the next successful Face ID prompt). The user does nothing differently; the SDK observes another 200 samples and rebuilds the baseline. No UI intrusion.
Pattern B, Explicit prompt. Some apps want to keep the user informed. On drift, show a non-intrusive banner: “We noticed your usage patterns have changed. We’ll update our authentication model to match.” The user taps “OK”; the SDK re-enrolls. This is the right pattern for healthcare apps where transparency is a regulatory expectation.
Pattern C, Step-up before re-enrollment. Highest-assurance pattern. On drift, before re-enrolling, prompt for biometric. The drift might be legitimate (user posture changed), but it might also be subtle account takeover where the new user happens to be establishing a similar distribution. Re-enrolling on top of a possibly-compromised baseline would launder the new user’s behavior into the baseline. A fresh biometric prompt confirms the user, then re-enrollment proceeds. BehaviorGuard’s requestStepUp(for: "Confirm your behavioral baseline update") is the call.
For credential-class apps (fintech, wallets), Pattern C is the right default. For healthcare and enterprise apps, Pattern A or B is appropriate. The integrator decides; the SDK is policy-agnostic.
The configuration knobs
BehaviorGuard exposes three drift-related configuration values:
let config = BehaviorGuardConfiguration(
// ...other params...
driftDetectionEnabled: true, // turn drift detection on
driftEvaluationInterval: 60, // re-compute PSI every 60 seconds
driftPSIThreshold: 0.20, // industry-standard meaningful-drift cutoff
// ...
)
The defaults are the right values for most apps. The threshold is the value most often tuned: a security-conservative app might lower it to 0.15 to catch drift earlier (at the cost of more frequent re-enrollment prompts); a friction-averse app might raise it to 0.25 to fire only on significant drift.
The integration shape
for await event in guardr.events() {
switch event {
case .baselineDriftDetected(let report):
switch report.confidence {
case .meaningful:
scheduler.scheduleReEnrollmentNudge(after: .minutes(30))
case .significant:
await guardr.requestStepUp(for: "Confirm your identity, baseline update")
// .stepUpVerified arrives in the same stream; act on that next
}
case .stepUpVerified:
await guardr.triggerReEnrollment()
default:
break
}
}
The drift detector runs continuously, silently, in the background. The integrator hears about it only when it fires, and decides the response. The architecture preserves the principle that runs through all of BehaviorGuard: the SDK emits typed events, the integrator decides policy, the audit log records both.
See /sdk/behaviorguard for the marketing summary, /docs/behaviorguard for the configuration reference, and the companion posts on continuous authentication and ten behavioral signals for the broader architectural and signal-level context.

Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.