A valid username and password is not proof of legitimate access — it is proof that someone knows the credentials. The question your auth system needs to answer is whether the entity submitting those credentials is the actual owner. Behavioral signals are the additional layer that distinguishes a legitimate login from a credential-stuffing bot or an account takeover attempt by a human attacker who bought the credentials from a breach database.
Device fingerprinting
A device fingerprint is a stable identifier derived from browser and device characteristics. It is not a perfect unique identifier, but it creates enough stability that a returning device can be recognized across sessions without requiring a persistent cookie.
// Server-side fingerprint construction from request headers
// These signals are combined into a stable hash
function buildDeviceFingerprint(req) {
const signals = {
userAgent: req.headers['user-agent'] || '',
acceptLanguage: req.headers['accept-language'] || '',
acceptEncoding: req.headers['accept-encoding'] || '',
// Timezone from client JS, sent as a header or request param
timezone: req.body.timezone || req.headers['x-timezone'] || '',
// Screen resolution and canvas fingerprint from client JS
screenRes: req.body.screen_res || '',
colorDepth: req.body.color_depth || '',
};
// Hash the combination — not reversible, but stable for the same device/browser
return createHash('sha256')
.update(JSON.stringify(signals))
.digest('hex')
.substring(0, 32);
}
// Compare against known devices for this user
async function checkDeviceFamiliarity(userId, fingerprint) {
const result = await db.query(`
SELECT last_seen_at, login_count
FROM known_devices
WHERE user_id = $1 AND fingerprint = $2
`, [userId, fingerprint]);
if (!result.rows[0]) {
return { known: false, riskScore: 30 }; // unknown device adds risk
}
return {
known: true,
lastSeen: result.rows[0].last_seen_at,
loginCount: result.rows[0].login_count,
riskScore: 0,
};
}
Impossible travel detection
If a user logs in from New York at 10:00 AM and then from London at 10:30 AM, that is physically impossible. The speed of travel required (the distance in miles divided by time in hours) exceeds what any aircraft could achieve. This is a strong signal of account takeover or shared credential usage.
const EARTH_RADIUS_KM = 6371;
function haversineDistance(lat1, lon1, lat2, lon2) {
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2) ** 2 +
Math.cos(lat1 * Math.PI / 180) *
Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon/2) ** 2;
return EARTH_RADIUS_KM * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
async function checkImpossibleTravel(userId, currentIp) {
const geo = await geolocate(currentIp);
if (!geo?.lat) return { suspicious: false };
// Get the most recent login location
const lastLogin = await db.query(`
SELECT ip_address, latitude, longitude, occurred_at
FROM login_events
WHERE user_id = $1 AND succeeded = true
ORDER BY occurred_at DESC
LIMIT 1
`, [userId]);
if (!lastLogin.rows[0]) return { suspicious: false };
const prev = lastLogin.rows[0];
const distanceKm = haversineDistance(prev.latitude, prev.longitude, geo.lat, geo.lon);
const elapsedHours = (Date.now() - new Date(prev.occurred_at).getTime()) / 3600000;
const requiredSpeedKmh = distanceKm / Math.max(elapsedHours, 0.016); // min 1 minute
// Commercial aircraft max ~900 km/h; allow 1000 to account for time zone edge cases
if (requiredSpeedKmh > 1000 && distanceKm > 500) {
return {
suspicious: true,
riskScore: 60,
detail: `${Math.round(distanceKm)} km in ${Math.round(elapsedHours * 60)} minutes`,
};
}
return { suspicious: false, riskScore: 0 };
}
Velocity checks
Velocity checks look at the rate of login attempts across various dimensions. A single IP making 100 login attempts in 1 minute is a bot. A single email address seeing failed attempts from 50 different IPs in an hour is a credential stuffing campaign targeting that account.
// Multi-dimensional velocity checks using Redis sliding windows
async function checkLoginVelocity(email, ip, orgId) {
const now = Date.now();
const minute = Math.floor(now / 60000);
const hour = Math.floor(now / 3600000);
// Pipeline all checks
const pipeline = redis.pipeline();
// IP-level: attempts from this IP in the last 10 minutes
pipeline.zcount(`vel:ip:${ip}`, now - 600000, now);
// Account-level: attempts on this account in the last hour
pipeline.zcount(`vel:acct:${email}`, now - 3600000, now);
// Org-level: total attempts on this org in the last minute
pipeline.zcount(`vel:org:${orgId}`, now - 60000, now);
// Global: total failed attempts in the last second
pipeline.zcount('vel:global', now - 1000, now);
const [ipCount, acctCount, orgCount, globalCount] = await pipeline.exec();
const signals = [];
let riskScore = 0;
if (ipCount[1] > 20) { signals.push('high_ip_velocity'); riskScore += 40; }
if (acctCount[1] > 10) { signals.push('targeted_account'); riskScore += 50; }
if (orgCount[1] > 100) { signals.push('org_under_attack'); riskScore += 20; }
if (globalCount[1] > 500){ signals.push('global_attack'); riskScore += 10; }
return { riskScore, signals };
}
Login time pattern anomaly
Every user has a behavioral pattern for when they log in. A software developer in San Francisco who always logs in between 8 AM and 8 PM Pacific time logging in at 3 AM Pacific from a new device is anomalous. Building a time-of-day model per user requires storing enough login history to establish a baseline, but after 20–30 logins, the pattern is informative.
// Build a per-user login time histogram (hour buckets in user's local timezone)
// After enough samples, score new logins by how unusual the hour is
async function checkLoginTimeAnomaly(userId, loginTimestamp, userTimezone) {
const localHour = new Date(loginTimestamp)
.toLocaleString('en-US', { timeZone: userTimezone, hour: 'numeric', hour12: false });
const hourBucket = parseInt(localHour);
const history = await db.query(`
SELECT EXTRACT(HOUR FROM
(occurred_at AT TIME ZONE $2)) as local_hour,
COUNT(*) as cnt
FROM login_events
WHERE user_id = $1 AND succeeded = true
AND occurred_at > NOW() - INTERVAL '90 days'
GROUP BY local_hour
`, [userId, userTimezone]);
if (history.rows.length < 10) return { riskScore: 0 }; // not enough data
const totalLogins = history.rows.reduce((sum, r) => sum + parseInt(r.cnt), 0);
const thisHourCount = history.rows.find(r => parseInt(r.local_hour) === hourBucket)?.cnt || 0;
const frequency = parseInt(thisHourCount) / totalLogins;
// If this hour accounts for less than 2% of historical logins, it's anomalous
return { riskScore: frequency < 0.02 ? 20 : 0 };
}
Compositing signals into a risk score
Individual signals mean little in isolation — many legitimate logins trigger one signal or another. Combining signals is where the model becomes useful. A new device (30 points) from a new country (25 points) at an unusual hour (20 points) gives a total of 75 points, which should trigger step-up MFA. The same new device on its own at a normal time from a known country scores 30 points — probably a new browser, proceed with low friction.
The output of the risk scorer maps to one of three actions: allow (score 0–30), require MFA if not already completed (score 30–70), or block and require email verification (score 70+). The score thresholds should be configurable per organization — a financial services org may want to step up at score 20 while a developer tools company may accept 60 before challenging.