Anomaly Detection
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.
| Caller | Route | rotated | Purpose |
|---|---|---|---|
protectRoute | Every authenticated request | false | Routine anomaly check during access-token verification |
rotateCredentials | POST /auth/user/refresh-session | true | Rotation-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
}
| Parameter | Type | Description |
|---|---|---|
token | string | The raw (unhashed) refresh token from the session cookie |
cookie | string | The canary_id cookie value. See canary_id lifecycle |
ipAddress | string | Client IP from req.ip |
ua | string | Raw User-Agent header string |
rotated | boolean | true 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_tokensprovides the token's validity, expiry, creation time, andusage_countusersprovides thevisitor_idforeign key and thelast_mfa_attimestampvisitorsprovides the stored fingerprint (IP, geo, User-Agent fields, device metadata, proxy/hosting flags, andsuspicious_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.
valid | reqMFA | Reason |
|---|---|---|
false | false | Token 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.
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,
})
2. Canary cookie mismatch
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.
valid | reqMFA | Reason |
|---|---|---|
false | true | new 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.
valid | reqMFA | Reason |
|---|---|---|
false | true | idle |
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.
valid | reqMFA | Reason |
|---|---|---|
false | true | more 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.
valid | reqMFA | Reason |
|---|---|---|
false | false | 3 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.
valid | reqMFA | Reason |
|---|---|---|
false | true | Ip 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.
valid | reqMFA | Reason |
|---|---|---|
false | true | Suspicion 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.
| Incoming | Stored flag | valid | reqMFA |
|---|---|---|---|
| Proxy | proxy_allowed: true | true | false |
| Proxy | proxy_allowed: false | false | true |
| Hosting | hosting_allowed: true | true | false |
| Hosting | hosting_allowed: false | false | true |
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:
| Category | Fields |
|---|---|
| Geo | country, city, district, lat, lon, timezone, currency, isp, org, as_org |
| Device | device, deviceVendor, deviceModel |
| Browser | browser, browserType, browserVersion, os |
Any single mismatch on a populated field triggers MFA.
valid | reqMFA | Reason |
|---|---|---|
false | true | Loop 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:
- Generating the
canary_idcookie on first contact - Resolving geo data from the client IP via local MMDB databases
- Parsing the User-Agent header into structured device, browser, and OS fields
- Upserting the visitor record with all fingerprint fields
- Accumulating a
suspicious_activity_scorethrough the checker pipeline
The auth service imports three helpers directly from @riavzon/bot-detector:
| Helper | Purpose | Used by |
|---|---|---|
getGeoData(ip) | Returns a GeoResponse object with geo fields, proxy, and hosting booleans | strangeThings (checks 8 and 9), getFingerPrint middleware |
parseUA(ua) | Returns a ParsedUAResult with device, browser, os, bot, botAI, and vendor info | strangeThings (check 9), getFingerPrint middleware |
updateVisitors(data, cookie, visitorId) | Updates the visitor row with fresh fingerprint fields | trustVisitor (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.
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:
- Updates
users.visitor_idto point at the visitor row associated with the currentcanary_id - 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
| Check | Trigger | reqMFA | Rationale |
|---|---|---|---|
| 1. Token validity | Token missing, revoked, or replayed | false | No identity to challenge. Session is dead. |
| 2. Canary mismatch | Different canary_id cookie | true | New device. User can prove identity. |
| 3. Idle | last_seen older than 24 hours | true | Stale session. Re-verify before continuing. |
| 4. Session count | >= maxAllowedSessionsPerUser | true | Too many devices. User picks which to keep. |
| 5. Rapid creation | More than 3 tokens in 10 minutes | false | Automated behavior. Hard block. |
| 6. IP range | IP outside stored range | true | Network change. User can confirm. |
| 7. Suspicion score | Score >= banScore * 0.25 | true | Bot-like behavior building up. Early challenge. |
| 8. Proxy/hosting | Proxy or hosting without allow flag | true | New infrastructure. One-time MFA gate. |
| 9. Fingerprint | Any geo or UA field mismatch | true | Environment changed. User can re-verify. |
Configuration reference
All options that affect anomaly detection:
| Option | Location | Type | Default | Description |
|---|---|---|---|---|
maxAllowedSessionsPerUser | jwt.refresh_tokens | number | — | Maximum concurrent valid refresh tokens before check 4 triggers |
byPassAnomaliesFor | jwt.refresh_tokens | number | — | Milliseconds after last_mfa_at during which session count check is skipped |
enableBotDetector | botDetector | boolean | — | Enables or disables the Bot Detector integration entirely |
banScore | botDetector.settings | number | 100 | Score threshold for banning a visitor. MFA triggers at 25% of this value |
trustUserDeviceOnAuth | Root config | boolean | false | Whether 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.