Skip to main content

Anomaly detection

WIP

Scoring whether a request with a valid session looks like the person it was issued to, with the device and impossible-travel detectors

A valid session can still arrive from somewhere it should not. auth.anomaly runs detectors over each request, combines what they notice into one score, and recommends allow, step-up or deny. It recommends only; the server adapters refuse a deny, and the rest is yours to act on.

Nothing is registered by default, and with no detectors the step is skipped entirely.

Registering detectors

import { authMemoryDeviceFingerprintStore, deviceFingerprintDetector, sha256 } from '@gentleduck/auth/core'

auth.anomaly.register(deviceFingerprintDetector({ store: authMemoryDeviceFingerprintStore(), authSha256: sha256 }))
DetectorSignalNeeds
deviceFingerprintDetectornew-device, scoring 0.7, the first time an identity is seen on a device (sha256 of the User-Agent and the IP's /24 or /48 network)A store. The memory one is per process; implement AuthDeviceFingerprint.IStore over shared storage for a fleet.
authImpossibleTravelDetectorimpossible-travel, when the distance from the last known position implies more than 900 km/hgetLastSeen(identityId), answering a position you record, and geo on the request.

A detector is any { id, evaluate(ctx) } answering signals { kind, score, evidence }, with score from 0 to 1.

Running them

The detectors need the request, so they run only when resolveSession gets a requestSnapshot. The server adapters' actor wrappers pass one once you give them getCaller:

app.use(
  expressActorContext(auth, {
    getCaller: (req) => ({ ...expressCaller(req), geo: lookupGeo(req.ip) }),
    onAnomaly: async (result) => {
      // called for every decision but 'allow', in place of the default refusal
      if (result.decision === 'deny') throw new AuthError('AUTH_ANOMALY_DENIED', { score: result.score })
      throw new AuthError('AUTH_STEP_UP_REQUIRED', { challenge: result.signals })
    },
  }),
)
  • Without onAnomaly, a deny is refused with AUTH_ANOMALY_DENIED (403) and a step-up is let through, since a new device alone reaches the step-up score and refusing it would demand MFA on every first visit.
  • With onAnomaly, the decision is yours: throw to refuse, including for a deny.
  • What a detector learns, such as a new device, is recorded only once the request is let through.

Calling resolveSession({ headers }, { requestSnapshot }) yourself, branch on result.anomaly?.decision and call result.anomaly?.admit() once you serve the request.

Scoring

Signals combine as 1 - (1 - a)(1 - b)..., so more evidence always raises the score and it never passes 1.

SettingDefaultEffect
stepUpAt0.7At or above: step-up.
denyAt0.95At or above: deny.
threshold0.7Emits suspicious for an allow scoring this much; every other decision always emits.
detectorTimeoutMs1000A detector slower than this is dropped for the request.
reactionsnonePer detector id, then signal kind ('*' for any detector): 'allow' mutes the signal, 'step-up' or 'deny' forces at least that.
createAuth({
  // ...
  anomaly: { denyAt: 0.9, reactions: { '*': { 'impossible-travel': 'deny' } } },
})

A detector that throws, times out or answers garbage is logged and skipped, so a broken plugin never locks users out, and a broken detector looks like one that found nothing. Watch the logs.