Behavioral signals for fraud detection in auth flows

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.

Keep a feedback loop. Track how often step-up MFA challenges are completed successfully (likely legitimate user) vs abandoned (likely blocked attacker or frustrated user). Tune thresholds based on your false positive rate. A challenge completion rate below 60% means your threshold is too low; users are abandoning the flow. Above 95% means your threshold may be too high and you are not catching enough real attacks.

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.

← Back to blog Try Bastionary free →