Adaptive Auth
Score every login by risk — new device, new country, impossible travel, attempt velocity — and react: allow, force a step-up, or deny. An opinionated engine that builds on the step-up and MFA gates, with geo you supply (no bundled GeoIP).
#Engine & Rules
Bind the known-device and login-history stores once with createRiskEngine. Four rules ship in-box — new_device, new_country, impossible_travel, velocity — and every rule's action and threshold is overridable.
import {
createNeonKnownDeviceStore,
createNeonLoginHistoryStore,
createRiskEngine
} from '@absolutejs/auth';
// Bind the two stores (and any overrides) once, then reuse the engine.
export const risk = createRiskEngine({
knownDeviceStore: createNeonKnownDeviceStore(process.env.DATABASE_URL),
loginHistoryStore: createNeonLoginHistoryStore(process.env.DATABASE_URL),
// Every rule's action + threshold is overridable:
rules: { new_country: 'allow' }, // e.g. don't step up just for a new country
maxTravelKmh: 1000, // impossible_travel threshold (default 900)
velocityMaxAttempts: 5 // deny after N attempts within velocityWindowMs
});
// Built-in rules (defaults): new_device -> step_up, new_country -> step_up,
// impossible_travel -> deny, velocity -> deny. The overall action is the most
// severe rule that fires (allow < step_up < deny).#Assess & Act
Call assessRisk in your login or OAuth-callback handler, where the request context (device, IP, geo) is available — the credentials MFA gate only sees the user, so adaptive auth is consumer-invoked by design. Record the attempt, then act on the verdict:
// In your login / OAuth-callback handler — where the request context exists —
// assess the attempt, record it, then act on the verdict.
const context = {
deviceId, // your device cookie / fingerprint
geo: await lookupGeo(ipAddress), // you own geo resolution (no bundled GeoIP)
ipAddress,
userId: user.sub
};
const { action, reasons } = await risk.assessRisk(context);
await risk.recordAttempt({ ...context, outcome: action });
if (action === 'deny') {
return status('Forbidden', { reasons }); // block outright
}
if (action === 'step_up') {
return status('OK', { status: 'mfa_required' }); // route into the MFA gate
}
// action === 'allow' -> promote the session as usual#Trusting Devices
Once the user clears the step-up, call trustDevice — the "remember this device" action — so the new_device rule stops firing for it. Device first/last-seen and the full attempt history persist in the two stores (in-memory for dev, Postgres/Neon for production).
// After the user clears the step-up (MFA verified), remember this device so the
// new_device rule stops firing for it next time:
await risk.trustDevice(user.sub, deviceId, 'Work laptop');#Weighted scoring & fingerprint
scoreRisk is an Auth0-style alternative to the per-rule actions: each fired signal adds its weight and the summed score maps to an action via thresholds. It adds two consumer-fed signals — proxy and off_hours (from isProxy / localHour). fingerprintDevice hashes client signals into a stable deviceId — a stronger default than a User-Agent string alone.
import { fingerprintDevice, scoreRisk } from '@absolutejs/auth';
// A stable device id from client signals (a better default than a UA string
// alone) — use it as the adaptive deviceId.
const deviceId = await fingerprintDevice({
userAgent, language, timezone, platform, screen, canvasHash
});
// Weighted scoring, an alternative to per-rule actions: each fired signal adds
// its weight; the summed score maps to an action via thresholds. proxy/off_hours
// fire only when you pass isProxy/localHour.
const result = await scoreRisk(
{
knownDeviceStore,
loginHistoryStore,
weights: { new_country: 30, velocity: 70 },
thresholds: { stepUp: 40, deny: 80 }
},
{ deviceId, geo, isProxy, localHour, userId: user.sub }
);
// result -> { action: 'step_up', score: 65, reasons: [...] }#Browser fingerprint client
@absolutejs/auth/fingerprint-client ships a browser-only collectDeviceFingerprint() that hashes canvas + AudioContext + WebGL + font enumeration + screen geometry into a stable base64url id — the FingerprintJS algorithm set, self-hostable, no SDK and no third-party request. Pair it with the server-side fingerprintDevice as a fallback for old clients.
// In the browser — the strong fingerprint client. Same shape as the server-side
// fingerprintDevice (returns a stable base64url deviceId), but the input is
// canvas + audio + WebGL + font enumeration + screen geometry instead of a UA
// string. Sub-100ms, runs on demand, no SDK / no third-party request.
import { collectDeviceFingerprint } from '@absolutejs/auth/fingerprint-client';
// On your signin page:
const deviceId = await collectDeviceFingerprint();
await fetch('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
headers: {
'content-type': 'application/json',
'x-client-fingerprint': deviceId
}
});
// On the server, prefer the client-provided id; fall back to fingerprintDevice
// (User-Agent only) for old clients:
const deviceId =
request.headers.get('x-client-fingerprint') ??
await fingerprintDevice({ userAgent: request.headers.get('user-agent') });