Olympus Docs
CookbookDefensive security

Risk-based authentication

Adaptive auth that escalates based on signals

Static "everyone uses MFA always" is friction. Risk-based auth applies more friction only when signals warrant, new device, unusual location, recent failures.

Signal sources

SignalSourceWeight
New device fingerprintTracked locallyHigh
New IP / countryTracked locally + geo-IPHigh
Recent failed logins on the accountlogin_attempts tableMedium
Off-hours loginRequest timestampLow
User-agent anomaly (e.g. headless)Request headerMedium
ASN reputation (known VPN, Tor)Third-partyMedium-high
Compromised password (HIBP)Already enforced at registrationn/a

Composite score

async function computeRiskScore(req, identity): Promise<number> {
  let score = 0;

  // New device
  const fingerprint = fingerprintRequest(req);
  const knownDevice = await checkKnownDevice(identity.id, fingerprint);
  if (!knownDevice) score += 30;

  // New location
  const geo = await geoLookup(getIp(req));
  const recentLocations = await getRecentLocations(identity.id, 30); // last 30 days
  if (!recentLocations.some((l) => l.country === geo.country)) score += 25;

  // Recent failures
  const recentFails = await db`
    SELECT COUNT(*) FROM login_attempts
    WHERE identifier = ${identity.email}
      AND success = false
      AND created_at > NOW() - INTERVAL '1 hour'
  `.first();
  if (recentFails > 3) score += 20;

  // Off-hours
  const hour = new Date().getUTCHours();
  if (hour < 6 || hour > 22) score += 5;

  // Headless / bot-ish UA
  const ua = req.headers["user-agent"] ?? "";
  if (/headless|curl|wget|python/i.test(ua)) score += 30;

  return score;
}

Decision based on score

const score = await computeRiskScore(req, identity);

if (score >= 60) {
  // Block or hard-require step-up
  return { action: "step_up_to_aal2", reason: "high_risk" };
}
if (score >= 30) {
  // Soft-require: prompt step-up unless already AAL2
  return { action: "soft_step_up_to_aal2", reason: "medium_risk" };
}
// Low risk, allow normal login.
return { action: "allow" };

Implementation

Run the risk evaluation in a Kratos hook (after.password.hooks) or in your app's post-login middleware.

If using a Kratos hook, the webhook can return a redirect:

return Response.json({
  redirect_to: "/self-service/login/browser?aal=aal2&refresh=true&reason=risk",
});

Kratos honors this and redirects the user.

Calibration

Risk-based auth requires tuning. Start permissive (high threshold for step-up) and tighten as you collect data:

  • Log scores for every login (not the decision, just the components).
  • Watch the distribution. Pick a threshold that step-ups <5% of normal logins.
  • Increase threshold during incidents.

Continuous authentication

Beyond login, monitor for risk during the session:

  • Sudden IP change mid-session → re-auth.
  • Anomalous request patterns → re-auth.

Olympus's session model is binary (logged-in or not). Continuous re-auth requires app-side logic above the session.

On this page