Fingerprinting

How the IAM service builds a composite device fingerprint from IP geolocation and user-agent parsing, how it persists and compares fingerprints for anomaly detection, and how to access fingerprint data in custom handlers.

Every time a sensitive request reaches the IAM service, the getFingerPrint middleware extracts a composite device fingerprint from the incoming request. The fingerprint combines IP-based geolocation data (country, city, coordinates, ISP, proxy/hosting flags) with parsed user-agent fields (browser, OS, device type, vendor, model) and a bot detection signal. This data is attached to req.fingerPrint and flows into three downstream systems: anomaly detection, MFA verification, and visitor trust management.

The fingerprint is not a browser-side fingerprint (no canvas hashing, no WebGL probing). It is a server-side composite built entirely from request headers and IP metadata. The service compares incoming fingerprints against stored visitor records to detect device changes, geographic shifts, and suspicious infrastructure usage.


Data collection

The getFingerPrint middleware runs on every route that involves session verification, token rotation, or MFA. It calls two functions from the Bot Detector package: getGeoData for IP geolocation and parseUA for user-agent parsing.

import { getFingerPrint } from '@riavzon/auth'

// Mount on any route that needs fingerprint data
router.post('/sensitive-action', getFingerPrint, async (req, res) => {
  const { ipAddress, country, city, browser, os, device } = req.fingerPrint
  // ...
})

The middleware populates req.fingerPrint with the full FingerPrint interface:

Geolocation fields

ipAddress
string required
The client IP address from req.ip. Used as the primary key for geo lookups and anomaly comparisons.
country
string
Full country name (e.g. 'Germany').
countryCode
string
ISO 3166-1 alpha-2 country code (e.g. 'DE').
region
string
Region or state code (e.g. 'BE' for Berlin).
regionName
string
Full region name (e.g. 'Berlin').
city
string
City name (e.g. 'Berlin').
district
string
District or subdivision within the city, when available.
lat
string
Latitude coordinate of the IP location.
lon
string
Longitude coordinate of the IP location.
timezone
string
IANA timezone identifier (e.g. 'Europe/Berlin').
currency
string
ISO 4217 currency code associated with the country (e.g. 'EUR').
isp
string
Internet Service Provider name.
org
string
Organization name associated with the IP range.
as_org
string
Autonomous System organization name.
proxy
boolean
Whether the IP is associated with a known proxy service. Triggers anomaly detection when true and the user has not been verified via MFA.
hosting
boolean
Whether the IP belongs to a known hosting or datacenter provider. Same anomaly behavior as proxy.

User-agent fields

userAgent
string required
The raw User-Agent header string, stored as-is for logging and comparison.
device
string required
Device type classification (e.g. 'desktop', 'mobile', 'tablet').
deviceVendor
string
Device manufacturer (e.g. 'Apple', 'Samsung').
deviceModel
string
Device model name (e.g. 'iPhone', 'Galaxy S24').
browser
string
Browser name (e.g. 'Chrome', 'Safari', 'Firefox').
browserType
string
Browser engine or type classifier.
browserVersion
string
Full browser version string (e.g. '125.0.0.0').
os
string
Operating system name (e.g. 'macOS', 'Windows 11', 'Android 14').
botAI
boolean required
Whether the user agent matches a known AI crawler pattern.
bot
boolean required
Whether the user agent matches a known bot pattern.
The geolocation data comes from the Bot Detector package, which uses databases generated by shield-base. The accuracy of city, lat, and lon depends on the database edition and the client's ISP.

Where fingerprints are collected

The getFingerPrint middleware is mounted on routes where the service needs to verify or update the client's identity:

RoutePurpose
POST /auth/user/refresh-sessionToken rotation. Fingerprint feeds into strangeThings() anomaly checks.
POST /auth/verify-mfaAdaptive MFA verification. Fingerprint is persisted via updateVisitors on success.
POST /custom/mfa/:reasonCustom MFA initiation. Fingerprint metadata is included in the OTP email.
GET /auth/verify-custom-mfaCustom MFA link validation.
POST /auth/verify-custom-mfaCustom MFA code submission.
POST /update/emailEmail update. Fingerprint is persisted after successful verification.
GET /auth/reset-passwordPassword reset link validation.
POST /auth/reset-passwordPassword reset submission.
GET /secret/dataBFF access route.
GET /secret/accesstoken/metadataBFF metadata route.
The login and signup controllers do not use getFingerPrint middleware directly. They extract device metadata through parseUA and getGeoData inline and pass it to the visitor creation flow.

Visitor persistence

When a user completes MFA verification or logs in from a new device, the service calls updateVisitors from the Bot Detector package to persist the fingerprint to the visitors table. This stored record becomes the baseline for future anomaly comparisons.

The visitors table stores one record per visitor_id (linked to the canary_id cookie). Each record contains the full set of geolocation and user-agent fields from the FingerPrint interface. When updateVisitors is called, it overwrites the stored record with the current request's fingerprint data.

When fingerprints are persisted

EventTriggerFunction
LoginSuccessful credential verificationupdateVisitors called with parsed request data
MFA verificationOTP code validatedverifyMfaCode calls updateVisitors after token rotation
Visitor trustNew device verifiedtrustVisitor calls updateVisitors inside a transaction

trustVisitor

trustVisitor is called when a user verifies a new device through MFA. It performs two operations inside a single database transaction:

  1. Updates the user's visitor_id to point to the newly verified visitor record
  2. Calls updateVisitors to persist the current fingerprint to that visitor record

If updateVisitors fails, the transaction rolls back and the user's visitor_id is not updated. This ensures the user is never associated with a visitor record that has incomplete fingerprint data.

import { trustVisitor } from '@riavzon/auth'

const result = await trustVisitor(
  userId,
  visitorIdToTrust,
  canaryId,
  req.fingerPrint,
  log
)

if (!result.ok) {
  // Transaction failed — visitor not trusted
  log.error(result.data)
}

Anomaly detection integration

The stored fingerprint is the reference point for anomaly detection. When strangeThings() runs on a refresh-token use, it joins the refresh_tokens, users, and visitors tables to compare the incoming request against the stored visitor record.

The following fingerprint fields are compared:

FieldAnomaly typeResult
canary_id cookie vs stored canary_idDevice changereqMFA: true (new device)
ipAddress vs stored IP rangeNetwork changereqMFA: true (different network)
proxy flagProxy detectedreqMFA: true if proxy_allowed = 0
hosting flagHosting/datacenter detectedreqMFA: true if hosting_allowed = 0
device typeDevice type changereqMFA: true
browser nameBrowser changereqMFA: true
os nameOS changereqMFA: true
lat / lon coordinatesGeographic shiftreqMFA: true (significant distance)
After a successful MFA verification, verifyMfaCode sets proxy_allowed = 1 and hosting_allowed = 1 on the visitor record. This grants trusted status to the verified device, suppressing proxy and hosting anomalies until the next suspicious event resets them.

The canary_id lifecycle

The canary_id is a long-lived browser cookie that serves as the persistent device identifier. It ties a specific browser instance to a visitor_id in the database.

A canary_id cookie is set when a user first interacts with the authentication system (signup or login). The value is a randomly generated identifier.

Storage linkage

The canary_id is stored alongside the user's visitor_id in the visitors table. The users table references the visitor_id, creating a chain: user → visitor_id → visitors table → canary_id.

Anomaly comparison

On every authenticated request, strangeThings() compares the canary_id from the incoming cookie against the value stored in the database. A mismatch indicates a new or different device, triggering an MFA challenge.

XSS integration

When an XSS attempt is detected, handleXSS uses the canary_id to permanently mark the corresponding visitor as a bot and update the banned IP record. This means the ban follows the device, not just the IP.


Accessing fingerprints in custom handlers

After the getFingerPrint middleware runs, the full fingerprint is available on req.fingerPrint. Use it for logging, analytics, or custom security logic.

import { getFingerPrint } from '@riavzon/auth'

router.post('/transfer',
  getFingerPrint,
  async (req, res) => {
    const fp = req.fingerPrint

    // Log the request context
    log.info({
      ip: fp.ipAddress,
      country: fp.country,
      browser: fp.browser,
      device: fp.device,
      proxy: fp.proxy,
    }, 'Transfer request')

    // Block requests from hosting providers
    if (fp.hosting) {
      return res.status(403).json({ error: 'Requests from hosting providers are not allowed.' })
    }

    // Include location in the confirmation email
    const location = [fp.city, fp.regionName, fp.country].filter(Boolean).join(', ')

    await sendConfirmationEmail(user.email, {
      amount: req.body.amount,
      location,
      device: `${fp.browser} on ${fp.os}`,
    })
  }
)
The getFingerPrint middleware fails gracefully. If getGeoData or parseUA throws (,the IP geolocation database is unavailable), the middleware logs the error and calls next() without populating req.fingerPrint. Always check for undefined fields when accessing geolocation data in production.
Logo