Risk-based (adaptive) MFA: when to step up and when to trust

Requiring MFA on every login is the safest policy and the most friction-filled one. Requiring MFA only on explicit opt-in is the least friction-filled and the most dangerous. Risk-based or adaptive MFA threads the needle: challenge users when behavioral signals suggest elevated risk, trust them when signals are normal. The implementation requires a risk scoring engine and a step-up auth flow that can interrupt a session mid-way to request additional authentication.

Risk signals and scoring

A risk score combines multiple signals, each contributing a partial score. The combined score maps to an action. Individual signals are weak; combinations are strong.

interface RiskSignal {
  name: string;
  score: number;   // 0-100 contribution to total risk
  reason: string;
}

async function computeLoginRiskScore(userId: string, context: LoginContext): Promise<{
  score: number;
  signals: RiskSignal[];
  action: 'allow' | 'require_mfa' | 'block';
}> {
  const signals: RiskSignal[] = [];

  // Signal: new device (fingerprint not seen before for this user)
  const deviceFamiliarity = await checkDeviceFamiliarity(userId, context.deviceFingerprint);
  if (!deviceFamiliarity.known) {
    signals.push({ name: 'new_device', score: 25, reason: 'Unrecognized device' });
  }

  // Signal: new country
  const lastLoginCountry = await getLastLoginCountry(userId);
  if (lastLoginCountry && lastLoginCountry !== context.country) {
    signals.push({ name: 'new_country', score: 30, reason: `Login from ${context.country}, last was ${lastLoginCountry}` });
  }

  // Signal: impossible travel
  const travelCheck = await checkImpossibleTravel(userId, context.ip);
  if (travelCheck.suspicious) {
    signals.push({ name: 'impossible_travel', score: 60, reason: travelCheck.detail });
  }

  // Signal: unusual hour (based on user's historical login times)
  const timeAnomaly = await checkLoginTimeAnomaly(userId, context.timestamp, context.timezone);
  if (timeAnomaly.riskScore > 0) {
    signals.push({ name: 'unusual_time', score: timeAnomaly.riskScore, reason: 'Login at unusual hour' });
  }

  // Signal: IP reputation (datacenter / known VPN / Tor)
  const ipRep = await checkIPReputation(context.ip);
  if (ipRep.category === 'datacenter') {
    signals.push({ name: 'datacenter_ip', score: 15, reason: 'Request from datacenter IP' });
  } else if (ipRep.category === 'tor') {
    signals.push({ name: 'tor_exit_node', score: 40, reason: 'Request from Tor exit node' });
  }

  const totalScore = Math.min(100, signals.reduce((sum, s) => sum + s.score, 0));

  let action: 'allow' | 'require_mfa' | 'block';
  if (totalScore < 30) action = 'allow';
  else if (totalScore < 70) action = 'require_mfa';
  else action = 'block';

  return { score: totalScore, signals, action };
}

Step-up authentication flow

Step-up auth allows a session to exist at a lower assurance level and be elevated when a high-risk action is attempted. A user may be logged in normally (password only) but need to complete MFA to access billing, export data, or change security settings. The JWT's amr (Authentication Methods References) claim records how the user authenticated, enabling downstream services to enforce assurance requirements.

// Step-up: require MFA for sensitive operations
// JWT amr claim records authentication methods used
// e.g., ["pwd"] = password only, ["pwd", "totp"] = password + TOTP

// Middleware: enforce minimum authentication level
function requireAuthLevel(minimumMethods: string[]) {
  return (req, res, next) => {
    const amr: string[] = req.user.amr || [];

    const satisfied = minimumMethods.every(method => amr.includes(method));
    if (!satisfied) {
      return res.status(403).json({
        error: 'insufficient_auth',
        required_methods: minimumMethods,
        current_methods: amr,
        step_up_url: '/auth/step-up',
      });
    }

    next();
  };
}

// Apply on sensitive routes
app.post('/billing/payment-method',
  requireAuthLevel(['pwd', 'mfa']),  // requires password + any MFA
  handlePaymentMethodUpdate
);

app.get('/org/export-data',
  requireAuthLevel(['pwd', 'mfa']),
  handleDataExport
);
// Step-up flow: user needs to complete MFA mid-session
// Generate a step-up challenge tied to the current session

async function initiateStepUp(sessionId: string, returnUrl: string) {
  const challengeId = crypto.randomUUID();

  await redis.setex(`step_up:${challengeId}`, 300, JSON.stringify({
    sessionId,
    returnUrl,
    requiredAt: Date.now(),
  }));

  return { challengeId, redirectTo: `/auth/step-up?challenge=${challengeId}` };
}

async function completeStepUp(challengeId: string, mfaToken: string, userId: string) {
  const challenge = await redis.get(`step_up:${challengeId}`);
  if (!challenge) throw new Error('Step-up challenge expired');

  const { sessionId, returnUrl } = JSON.parse(challenge);

  // Verify MFA
  await verifyMFA(userId, mfaToken);

  // Upgrade session's amr claim
  await upgradeSessionAssurance(sessionId, 'mfa');

  // Issue new access token with elevated amr
  const newToken = await issueAccessToken(userId, { amr: ['pwd', 'mfa'] });

  await redis.del(`step_up:${challengeId}`);

  return { newToken, returnUrl };
}

The amr claim

RFC 8176 defines the Authentication Methods References claim. Standard values include:

  • pwd — password authentication
  • otp — one-time password (TOTP/HOTP)
  • hwk — proof-of-possession of hardware-secured key (WebAuthn)
  • pop — proof of possession of a key (passkey)
  • mfa — multiple authentication factors (any combination)
  • sso — SSO authentication
// JWT with amr claim
{
  "sub": "user_ABC",
  "org": "org_XYZ",
  "amr": ["pwd", "totp"],     // password + TOTP completed
  "aal": 2,                   // Authentication Assurance Level: 1=password, 2=MFA, 3=hardware
  "auth_time": 1633305600,    // when authentication occurred (for session age checks)
  "exp": 1633306500
}

// Check AAL in middleware
function requireAAL(minimumLevel: 1 | 2 | 3) {
  return (req, res, next) => {
    if ((req.user.aal || 1) < minimumLevel) {
      return res.status(403).json({ error: 'insufficient_assurance', required_aal: minimumLevel });
    }
    next();
  };
}
Store auth_time in the JWT and check it for sensitive operations that require recent authentication. A user who logged in 8 hours ago and has been idle should re-authenticate before performing a destructive action, even if their MFA is satisfied. Banks call this "step-up" even within an authenticated session — a reasonable policy is to require re-authentication for actions that occurred more than 30 minutes after the last authentication event.

Configuring risk thresholds per organization

Enterprise customers have different risk tolerances. A financial services org may want MFA required on every login, no exceptions. A developer tools startup may want adaptive MFA with a high threshold to minimize friction. Model this as a per-org policy:

// Per-org MFA policy
interface OrgMFAPolicy {
  mfa_required: 'always' | 'adaptive' | 'optional';
  adaptive_threshold: number;   // risk score threshold for requiring MFA (default: 30)
  require_for_admin: boolean;   // always require MFA for admin roles
  allow_remember_device: boolean;  // reduce friction for known devices
  remember_device_days: number;
}

Adaptive MFA with a well-tuned risk engine reduces MFA prompts by 60–80% for normal logins while still catching the account takeover attempts that matter. The key metric to track: MFA challenge rate on successful logins. If it is above 20%, your thresholds may be too low. Below 2%, you may be missing genuine threats. Tune against your observed attack patterns, not against a generic baseline.

← Back to blog Try Bastionary free →