Anomaly Detection

How the IAM service inspects every refresh-token use for behavioral anomalies, which checks it runs, and when each one triggers adaptive MFA or a hard block.

Every time a refresh token is used, the IAM service runs strangeThings() to decide whether the request is legitimate, suspicious, or hostile. The function queries the refresh_tokens, users, and visitors tables in a single lookup, then runs nine sequential checks against the results. The first check that fails short-circuits the rest and returns immediately.

Each failure carries a reqMFA flag. When reqMFA is true, the caller sends an email OTP challenge and the user can recover by proving their identity. When reqMFA is false, the request is a hard block: the token is revoked and the user must log in again.


Routes

strangeThings is called from two places in the middleware chain, each with a different value for the rotated parameter.

CallerRouterotatedPurpose
protectRouteEvery authenticated requestfalseRoutine anomaly check during access-token verification
rotateCredentialsPOST /auth/user/refresh-sessiontrueRotation-time check with reuse detection enabled

When rotated is true, the function adds an extra condition: if the token has already been consumed (usage_count > 0), the request is hard-blocked. This catches replay attacks during the rotation window. When rotated is false, the function skips the usage_count check because consumeAndVerifyRefreshToken handles reuse detection separately downstream.

See Refresh Tokens for the full rotation and consumption flow.


Function signature

import { strangeThings } from '@riavzon/auth'

const { valid, reason, reqMFA, userId, visitorId } = await strangeThings(
  rawRefreshToken,          // Raw token string from the client cookie
  canary_id,                // canary_id cookie value
  req.ip!,                  // Client IP address
  req.get('User-Agent')!,   // Raw User-Agent header
  false                     // true when called from the rotation controller
)

if (!valid && reqMFA) {
  // Issue adaptive MFA email — see MFA docs
} else if (!valid) {
  // Hard block — revoke session and return 401
}
ParameterTypeDescription
tokenstringThe raw (unhashed) refresh token from the session cookie
cookiestringThe canary_id cookie value. See canary_id lifecycle
ipAddressstringClient IP from req.ip
uastringRaw User-Agent header string
rotatedbooleantrue when the token has already been rotated this request

The function hashes the token with SHA-256 before querying the database. No plaintext token ever reaches the database layer.


The data lookup

Before any checks run, strangeThings check the local LRU cache from anomaliesCache. if the cache exists and it marked as unresolved anomaly it short circuit and returns the cache results from the previous run.

When no cache exists, or it marked as resolved, strangeThings joins three tables in a single query:

  • refresh_tokens provides the token's validity, expiry, creation time, and usage_count
  • users provides the visitor_id foreign key and the last_mfa_at timestamp
  • visitors provides the stored fingerprint (IP, geo, User-Agent fields, device metadata, proxy/hosting flags, and suspicious_activity_score)

The join key chain is: refresh_tokens.user_id to users.id to users.visitor_id to visitors.visitor_id. If no row is found, the function returns { valid: false, reqMFA: false } immediately. The visitors table is managed by the Bot Detector middleware and is described in Device Fingerprinting.

The cache is a LRU instance available for library users via anomaliesCache, and its keyed by a hashed refresh token.

The cache body:

interface AnomaliesCache {
    anomalyType: string,
    canaryCookie: string
    visitorId?: string
    userId?: number
    resolved: boolean;
    resolvable: boolean;
}

An entry is deleted when a user complete successfully a OTP challenge send to its email, or when the callers fails sending this email.


The nine checks

1. Token validity and reuse

The function inspects the token row from the database. If the token does not exist, has been revoked (valid = false), or has already been consumed while rotated is true, the token is revoked and the request is blocked.

validreqMFAReason
falsefalseToken not found, already invalid, or replayed

The revocation call ensures the token row is marked valid = 0 even if the row was still technically valid but replayed. This prevents the same token from being retried.

When a replayed token is detected during rotation (rotated = true and usage_count > 0), the service revokes the session. The separate consumeAndVerifyRefreshToken path handles mass revocation (revoking all sessions for the user) when a fully consumed token is presented a second time. Together, these two detection points make replay attacks observable from both the rotation controller and the consumption path. See Reuse Detection for the full scenario walkthrough.

Cache:

cache.set(hashedClientToken, {
     anomalyType: 'No token found',
     canaryCookie: cookie,
     resolved: false,
     resolvable: false,
})
cache.set(hashedClientToken, {
  anomalyType: 'token is invalid or being used more then ones',
  canaryCookie: cookie,
  resolved: false,
  resolvable: false,
})

The stored canary_id in the visitors table is compared against the canary_id cookie sent with the request. A mismatch means the request is arriving from a different browser or device than the one that established the session. MFA is required so the user can verify their identity from the new context.

validreqMFAReason
falsetruenew device

The canary_id is a cryptographically random 64-character hex string set as an httpOnly, Secure, SameSite=Lax cookie with a 90-day TTL. It is generated by the Bot Detector middleware on first contact and used as the primary key in the visitors table. See canary_id lifecycle for how it flows through login and anomaly detection.

Cache:

cache.set(hashedClientToken, {
  anomalyType: 'new device',
  canaryCookie: cookie,
  resolved: false,
  resolvable: true,
  userId: user_id,
  visitorId: visitor_id,
})

3. Idle detection

If the visitor's last_seen timestamp is older than 24 hours, the session is flagged as idle. The assumption is that a legitimate user returning after a long gap should re-verify. MFA is triggered.

validreqMFAReason
falsetrueidle

The last_seen column in the visitors table is updated automatically by the Bot Detector on every request. It reflects the last time any request (not just auth requests) was seen from that canary_id.

Cache:

cache.set(hashedClientToken, {
  anomalyType: 'idle',
  canaryCookie: cookie,
  resolved: false,
  resolvable: true,
  userId: user_id,
  visitorId: visitor_id,
})

4. Session count limit

The function counts all valid (non-expired, non-revoked) refresh tokens for the user. If the count equals or exceeds maxAllowedSessionsPerUser, MFA is required. This prevents an attacker from silently opening many sessions.

validreqMFAReason
falsetruemore than N active sessions

This check respects the byPassAnomaliesFor cooldown. If the user completed MFA within the configured window, the session count check is skipped entirely. See MFA Bypass Window.

Cache:

cache.set(hashedClientToken, {
  anomalyType: `more than X active sessions`,
  canaryCookie: cookie,
  resolved: false,
  resolvable: true,
  userId: user_id,
  visitorId: visitor_id,
})

5. Rapid token creation

If the user has created more than three valid tokens in the last 10 minutes, the current token is revoked immediately. This pattern indicates an automated attack such as a race condition exploit or a token-farming script. No MFA is offered because the speed of creation suggests a non-human actor.

validreqMFAReason
falsefalse3 tokens in less than 10 min

Cache:

cache.set(hashedClientToken, {
  anomalyType: `3 tokens in less than 10 min`,
  canaryCookie: cookie,
  resolved: false,
  resolvable: false,
})

6. IP range mismatch

The incoming IP address is compared against the stored ip_address in the visitors table using the ip-range-check library. If the current IP does not fall within the same range as the stored address, MFA is triggered. This catches IP changes caused by VPN switches or network hops while allowing normal ISP rotation within the same subnet.

validreqMFAReason
falsetrueIp does not match

Cache:

cache.set(hashedClientToken, {
  anomalyType: 'Ip does not match',
  canaryCookie: cookie,
  resolved: false,
  resolvable: true,
  userId: tokenResults.user_id,
  visitorId: tokenResults.visitor_id,
})

7. Suspicious activity score

Every visitor accumulates a suspicious_activity_score through the Bot Detector scoring pipeline. The anomaly engine triggers MFA when the score reaches or exceeds 25% of the configured banScore. The 25% threshold is intentional: MFA challenges start well before the visitor reaches the ban threshold, giving legitimate users a chance to prove themselves while slowing down attackers.

validreqMFAReason
falsetrueSuspicion score to high

The banScore defaults to 100 and is configurable through the botDetector.settings.banScore option. At the default, MFA triggers at a score of 25. See Bot Detector Configuration for the full scoring options.

Cache:

cache.set(hashedClientToken, {
  anomalyType: 'Suspicion score to high',
  canaryCookie: cookie,
  resolved: false,
  resolvable: true,
  userId: tokenResults.user_id,
  visitorId: tokenResults.visitor_id,
})

8. Proxy and hosting detection

The incoming IP is geolocated by calling getGeoData() from the Bot Detector. This function queries the local MMDB databases and returns geo information along with proxy and hosting boolean flags. The hosting flag is true when the ASN classification is "Content" or when the IP belongs to a known Tor exit node.

If the request arrives through a proxy or hosting provider, the service checks the visitor's proxy_allowed and hosting_allowed flags in the visitors table.

IncomingStored flagvalidreqMFA
Proxyproxy_allowed: truetruefalse
Proxyproxy_allowed: falsefalsetrue
Hostinghosting_allowed: truetruefalse
Hostinghosting_allowed: falsefalsetrue
When a proxy or hosting request is allowed (flags are true), the function returns valid: true immediately and skips the fingerprint consistency loop (check 9). This is by design: proxy and hosting environments often rotate IPs and geo data frequently, which would cause false positives in the fingerprint comparison. Once a user has been MFA-verified on a proxy connection, subsequent requests from any proxy are trusted without field-by-field comparison.

Both flags default to false in the visitors table. They are set to true during MFA verification. When a user completes an MFA challenge that was triggered by proxy or hosting detection, the proxy_allowed and hosting_allowed columns are both updated to true on the visitor record. This means MFA is a one-time gate per device for proxy/hosting users.

Cache:

cache.set(hashedClientToken, {
  anomalyType: 'Proxy Or hosting',
  canaryCookie: cookie,
  resolved: false,
  resolvable: true,
  userId: tokenResults.user_id,
  visitorId: tokenResults.visitor_id,
})
cache.set(hashedClientToken, {
  anomalyType: 'Proxy or hosting allowed',
  canaryCookie: cookie,
  resolved: true,
  resolvable: false,
  userId: tokenResults.user_id,
  visitorId: tokenResults.visitor_id,
})

9. Device fingerprint consistency loop

The final check runs only when the request is not coming through a proxy or hosting provider (or when the user's flags have not been set yet). The function builds a fingerprint object by merging the incoming geo data (from getGeoData) with the parsed User-Agent fields (from parseUA) and iterates over every key in the result. For each key that also exists in the stored visitor record, it compares the values.

The comparison skips a field if either side is null, undefined, or the string 'unknown'. This prevents false positives on fields that were not populated during the initial fingerprint capture.

Compared fields include:

CategoryFields
Geocountry, city, district, lat, lon, timezone, currency, isp, org, as_org
Devicedevice, deviceVendor, deviceModel
Browserbrowser, browserType, browserVersion, os

Any single mismatch on a populated field triggers MFA.

validreqMFAReason
falsetrueLoop detected

Cache:

cache.set(hashedClientToken, {
  anomalyType: 'Loop detected',
  canaryCookie: cookie,
  resolved: false,
  resolvable: true,
  userId: tokenResults.user_id,
  visitorId: tokenResults.visitor_id,
})

If all nine checks pass, the function returns { valid: true, reason: 'Checks passed', reqMFA: false },

and sets:

cache.set(hashedClientToken, {
  anomalyType: 'Checks passed',
  canaryCookie: cookie,
  resolved: true,
  resolvable: false,
  userId: tokenResults.user_id,
  visitorId: tokenResults.visitor_id,
})

Device fingerprinting and the Bot Detector

The anomaly engine relies on the visitors table, which is created and managed by the Bot Detector module. The Bot Detector runs as Express middleware (detectBots) before any auth routes and is responsible for:

  1. Generating the canary_id cookie on first contact
  2. Resolving geo data from the client IP via local MMDB databases
  3. Parsing the User-Agent header into structured device, browser, and OS fields
  4. Upserting the visitor record with all fingerprint fields
  5. Accumulating a suspicious_activity_score through the checker pipeline

The auth service imports three helpers directly from @riavzon/bot-detector:

HelperPurposeUsed by
getGeoData(ip)Returns a GeoResponse object with geo fields, proxy, and hosting booleansstrangeThings (checks 8 and 9), getFingerPrint middleware
parseUA(ua)Returns a ParsedUAResult with device, browser, os, bot, botAI, and vendor infostrangeThings (check 9), getFingerPrint middleware
updateVisitors(data, cookie, visitorId)Updates the visitor row with fresh fingerprint fieldstrustVisitor (called during login)

The getFingerPrint middleware in the auth service calls both getGeoData and parseUA and attaches the merged result to req.fingerPrint. This middleware runs in the rotation and MFA routes so that the caller has access to the current fingerprint data. The anomaly engine itself calls getGeoData and parseUA independently for checks 8 and 9 rather than reading from req.fingerPrint, because strangeThings receives the raw IP and User-Agent as parameters and does its own resolution.

canary_id lifecycle

The canary_id cookie is the thread that ties a browser session to a visitor record across authentication events. Here is how it flows:

First visit

The Bot Detector middleware generates a 64-character hex string using randomBytes(32) and sets it as the canary_id cookie. A visitor row is inserted with this value as the primary key, along with the initial geo and UA fingerprint.

Login

The login controller reads the canary_id from req.cookies. If trustUserDeviceOnAuth is enabled and the bot-detector assigned a new visitor_id for this request (req.newVisitorId), the controller calls trustVisitor() to update the user's visitor_id foreign key to point at the new visitor row. This re-baselines the fingerprint so that subsequent anomaly checks compare against the device the user actually logged in from. See Trusted devices on login.

Token rotation

The rotateCredentials controller passes the canary_id cookie to strangeThings() where it is compared against the stored value (check 2). If it matches, the rest of the checks proceed. If it does not match, MFA is triggered.

MFA completion

After a successful MFA challenge, the service updates last_mfa_at on the user row and sets proxy_allowed and hosting_allowed to true on the visitor record. This allows subsequent proxy/hosting requests from the same canary_id to pass without further MFA.


MFA bypass window

When a user completes MFA, the last_mfa_at timestamp is updated in the users table. For the duration of byPassAnomaliesFor milliseconds after that timestamp, check 4 (session count limit) is skipped. This prevents a user from being MFA-challenged again immediately after verifying a new device or session.

jwt: {
  refresh_tokens: {
    byPassAnomaliesFor: 300_000   // 5 minutes
  }
}

The bypass only applies to the session count check. All other checks still run regardless of when MFA was last completed.

When a refresh token expires, the verification functions set last_mfa_at to NULL on the user row. This resets the byPassAnomaliesFor cooldown window. The next time the user hits any anomaly condition that checks the bypass, it will not be skipped. A clean login from the same device with no anomalies still passes without MFA.

Trusted devices on login

The trustUserDeviceOnAuth configuration option controls whether the login controller re-baselines the user's visitor record. When set to true, the login flow calls trustVisitor() after a successful password check. This function:

  1. Updates users.visitor_id to point at the visitor row associated with the current canary_id
  2. Calls updateVisitors() from the Bot Detector to overwrite the visitor fingerprint with the current request's geo and UA data

The effect is that the stored fingerprint always reflects the device the user most recently logged in from. Without this option, the fingerprint stays fixed to whatever device was first associated with the user, which can cause false positives when users legitimately switch devices.

The same logic runs in the OAuth flow after a successful external provider callback.


Return value

interface AnomalyResult {
  valid: boolean
  reason: string        // Human-readable description of the outcome
  reqMFA: boolean       // true = issue MFA; false = hard block or pass
  userId?: number       // Present when reqMFA is true
  visitorId?: string    // Present when reqMFA is true
}

When reqMFA is true, userId and visitorId are included so the caller can construct and send the MFA email without an additional database lookup. See MFA for the full challenge and verification flow.

When reqMFA is false and valid is false, the session has been hard-blocked. The token is already revoked and the client must re-authenticate from scratch.


Decision summary

CheckTriggerreqMFARationale
1. Token validityToken missing, revoked, or replayedfalseNo identity to challenge. Session is dead.
2. Canary mismatchDifferent canary_id cookietrueNew device. User can prove identity.
3. Idlelast_seen older than 24 hourstrueStale session. Re-verify before continuing.
4. Session count>= maxAllowedSessionsPerUsertrueToo many devices. User picks which to keep.
5. Rapid creationMore than 3 tokens in 10 minutesfalseAutomated behavior. Hard block.
6. IP rangeIP outside stored rangetrueNetwork change. User can confirm.
7. Suspicion scoreScore >= banScore * 0.25trueBot-like behavior building up. Early challenge.
8. Proxy/hostingProxy or hosting without allow flagtrueNew infrastructure. One-time MFA gate.
9. FingerprintAny geo or UA field mismatchtrueEnvironment changed. User can re-verify.

Configuration reference

All options that affect anomaly detection:

OptionLocationTypeDefaultDescription
maxAllowedSessionsPerUserjwt.refresh_tokensnumberMaximum concurrent valid refresh tokens before check 4 triggers
byPassAnomaliesForjwt.refresh_tokensnumberMilliseconds after last_mfa_at during which session count check is skipped
enableBotDetectorbotDetectorbooleanEnables or disables the Bot Detector integration entirely
banScorebotDetector.settingsnumber100Score threshold for banning a visitor. MFA triggers at 25% of this value
trustUserDeviceOnAuthRoot configbooleanfalseWhether login re-baselines the visitor fingerprint to the current device

See Configuration for the full schema reference and Bot Detector Configuration for the scoring and checker pipeline options.

Logo