Skip to main content
Most logins are routine. A few are not: a session arriving through Tor, an anti-detect browser, or a brand-new device in a new country for an existing account. This pattern scores the login in real time, then your auth code uses a threshold ladder to decide who passes, who gets a second factor, and who gets your hardest verification path.

What is step-up authentication (risk-based 2FA)?

Step-up authentication, also called risk-based or adaptive authentication, raises the verification bar only for logins that look risky, instead of forcing a second factor on every user. A risk signal at sign-in — an unfamiliar device, a masked connection, an impossible location — triggers an extra challenge such as an OTP or a stronger verification path, while routine logins pass with no friction.

How ShieldLabs surfaces it

ShieldLabs scores each login as a Risk Score (0–100) with the anonymity signals behind it, and resolves the session to a set of identifiers. Three things drive the gate: the UserHID (your hashed account id, the account signing in), the durable, server-derived DeviceID, and the session’s anonymity. The DeviceID is the anchor — it survives a cookie clear, incognito, and IP rotation, so a familiar machine stays familiar and a new one stands out even when the attacker resets everything visible in the browser. ShieldLabs returns the score and the signals; the allow, require-2FA, or hold-for-verification decision is logic you write in your login flow.

Gate the login on the score

The rule your code applies: read the session’s risk_score and per-signal weight, then walk a threshold ladder — below 30 issue the session, 30 to 59 require a second factor, 60 and up route to your strongest verification or hold and alert. Pair the score with the account’s history: a brand-new DeviceID, or a new country read from local_ip.country (the real network behind any VPN, where public_ip.country is whatever the exit advertises), is a stronger step-up trigger than the score alone. When the two countries disagree, detection_flags.ip_mismatch is set to true. The outcome: a familiar device with a clean score passes untouched, while a Medium-or-High score, a new DeviceID, or an active ip_mismatch flag on a previously-clean account steps up.

Build it

1

Create a ShieldLabs account and get your keys

Sign up for free and get 5,000 identifications, or log in if you already have an account. Register the domain you want to identify visitors on, then open the Keys page. Use the Public Key to initialize the snippet in the browser, and keep your server-side credentials on your backend: the Private API Key authenticates the History API, and each webhook endpoint has its own whsec_… signing secret. See Keys.
2

Check the session at the login page

Add the snippet to your login page. Once you know which account is signing in (for example after the username field), pass its hashed id to checkAuthenticatedUser — never a raw email or user id. For a high-value account or a sensitive flow, use forceCheckAuthenticatedUser at submit instead: it clears the session and runs a fresh identify call immediately, so you score the login as it is now rather than a stale read from page load.
login.html
<script type="module">
  const mod = await import(
    'https://cdn.shieldlabs.ai/snippet.js?publicKey=YOUR_PUBLIC_KEY'
  );

  // Run a check for the account that is logging in.
  // The browser does NOT compute the Risk Score. The first callback arg is the
  // client IP the server saw for this call (not a score); requestID is your
  // join key to the webhook you receive server-side.
  // Use forceCheckAuthenticatedUser instead for a fresh read at submit.
  mod.checkAuthenticatedUser('a1b2c3d4hasheduserid', (ip, requestID) => {
    document.getElementById('shield-request-id').value = requestID;
  });
</script>

<form id="login-form" method="POST" action="/api/login">
  <input type="hidden" id="shield-request-id" name="shieldRequestId" />
  <input type="text" name="username" placeholder="Username" />
  <input type="password" name="password" placeholder="Password" />
  <button type="submit">Sign in</button>
</form>
The first callback arg is the client IP the server saw for this call, not a score; requestID is your join key to the webhook you receive server-side. Installing the snippet covers the React, Vue, Angular, Preact, and Svelte versions of the same pattern.
3

Receive the webhook and cache it by RequestID

ShieldLabs POSTs one signed webhook per scored identification. Verify X-Shield-Signature on the raw body, then cache the result keyed by request_id so the login request can read it back. That handler is the shared scoreCache / waitForScore helper defined once in the Use Case Tutorials; the device_id, country, and user_hid you compare below all ride in on the same flat payload. Delivery is at-most-once with no retries, so for a guaranteed read fall back to the History API by request_id (or user_hid to also pull the account’s recent sessions). Each returned History row bills 1 request and the webhook is free, so reserve History reads for the borderline logins.
4

Walk your threshold ladder

Wait briefly for the score, then branch. The band is your starting point; the signals array refines it. Below is a three-rung ladder you can tune to your own traffic. The actions (require 2FA, route to strong verification, hold and alert) all run in your application — ShieldLabs only returns the score and its signals.
api/login.js
app.post('/api/login', async (req, res) => {
  const { username, password, shieldRequestId } = req.body;

  // 1. Your normal credential check first.
  const user = await verifyPassword(username, password);
  if (!user) return res.status(401).json({ error: 'invalid_credentials' });

  // 2. Wait up to ~2s for the webhook; fall back to the History API by request_id.
  const shield = await waitForScore(shieldRequestId, 2000);

  // 3. No result yet is not the same as "clean". Default to requiring 2FA
  //    rather than letting a login through on missing data.
  if (!shield) {
    return res.status(200).json({ status: 'require_2fa', reason: 'verifying' });
  }

  const score = shield.score;

  // YOUR threshold ladder. Branch on the Score and its band, never on the
  // signal label text, which is a human-readable string that can change.
  // The bands are a guide, not a rule.
  if (score >= 60) {
    // High: strong anonymity signals folded into the score. Require
    // your strongest factor, or hold and alert the account owner.
    await alertAccountOwner(user.id, shield);
    return res.status(200).json({ status: 'verify', method: 'strong' });
  }

  if (score >= 30) {
    // Medium: one moderate signal or several overlapping. Require a second factor.
    return res.status(200).json({ status: 'require_2fa', method: 'otp' });
  }

  // Clean / Low: issue the session, no extra friction.
  return issueSession(user, res);
});
waitForScore polls the shared webhook cache, then falls back to a History API read by request_id.
5

Tune to your traffic

Start in a logging-only mode, watch how your real logins distribute across the bands, then raise friction where the data justifies it. A high-value account is a good place to draw the lines tighter.

The threshold ladder, band by band

The four bands and their ranges are defined once in Risk Scoring, and the cross-scenario action playbook lives in Acting on the Risk Score. Mapped to a login gate, a sensible starting ladder is:
BandSuggested login action
Clean (0–9)Issue the session, no friction
Low (10–29)Allow, log the signals
Medium (30–59)Require a second factor (OTP, authenticator)
High (60–100)Route to your strongest verification, or hold and alert the account owner
Where you draw each line is yours, and a high-value account is a good place to draw it tighter.

Signals worth weighting at login

The Risk Score already folds these in. If you want to raise friction even at a moderate score when a heavily weighted signal is present, look at each entry’s weight (its numeric contribution) rather than matching the signal label text, which can change. The names below are the customer-facing labels that appear in the array, shown for context, and the Signals reference lists the full set with what each one means.
Signal in signalsWhy it matters at login
TorConnection exits through the Tor network. Rare for a legitimate sign-in. Usually a strong-verification path decided by you.
JavaScript DisabledThe login ran without the script environment a real browser provides (headless or automation). Near-certain non-human login traffic; on its own it lands in the High band.
Anti-detect BrowserFingerprint-spoofing indicators. A common shape behind credential-stuffing follow-up and account takeover.
Abuser FlagIP or device on an abuse reputation list. Treat as high risk even at a moderate score.
OS MismatchThe OS the browser claims does not match other evidence. A spoofing indicator.
VPN / Privacy RelayCommon for legitimate, privacy-conscious users. Weaker evidence on its own. Weigh with the rest of the signals, do not gate on it alone.
For quick boolean branching at the gate, the payload also carries a detection_flags object — detection_flags.tor, detection_flags.anti_detect_browser, detection_flags.ip_mismatch, and so on — so you can branch without inspecting the signals array.
Pair the score with what you already know about the account. A Medium score on a login from a brand-new DeviceID and a new country for that UserHID is a stronger takeover signal than the same score on a familiar device. Pull the account’s recent sessions with a History API read by user_hid to compare the current device and country against its history.
Pull an account's recent sessions
curl "https://account.shieldlabs.ai/api/v1/history/user_hid/a1b2c3d4hasheduserid?limit=50" \
  -H "Authorization: Bearer sec_your_private_api_key"
The response is a { data, total } envelope; data is an array of snapshots (newest first), each in snake_case carrying device_id, country (the public IP’s country), score, and score_details (a JSON string you parse for the signal list) for that session. The shared shieldHistory helper returns this data array for you. A new device_id and country combined on an established account is the shape behind the account-takeover pattern. Account History reads on account.shieldlabs.ai are free; the Management History path bills 1 request per returned row, so reserve any billed reads for the borderline logins.

Honest caveat

A legitimate user can score high. Corporate VPNs, privacy browsers, and iCloud Private Relay all raise the Risk Score for real people signing in, so a blanket block on the High band will lock out genuine customers. Requiring a second factor rather than a hard block on the upper rungs keeps real users in while still slowing an attacker who only has the password. Decide on the score plus its signals plus the action context, never the number alone.

Test it

Confirm the durable identity before you wire thresholds to real friction. Sign in once and note the DeviceID on the webhook, then reproduce the “new arrival” without a new device:
  • Clear cookies and storage, then sign in again. The CookieID and VisitorID change, but the DeviceID stays the same.
  • Open an incognito or private window and sign in. The same DeviceID returns.
  • Switch networks (or turn on a VPN) so your IP changes, then sign in. The DeviceID holds; the signals array now also carries the masking signal (for example VPN), which raises the score.
A genuinely different machine — a second physical browser on another device — returns a different DeviceID, which is the shape your ladder should step up on. This is the test that proves a fresh cookie or a rotated IP cannot fake a familiar device.

Next steps

Acting on the Risk Score

The full per-band decision playbook, including signal-aware decisioning and how to combine the score with specific signals.

Signals

Every signal that can appear in signals, in plain language, with its weight.

The Risk Score

How the 0 to 100 score is built, what signals carries, and the band definitions.

Checkout and Payment Protection

The same pattern applied to the payment step, where anonymity signals warrant a harder response.

Account Takeover

Why a new DeviceID and country on an established account is the shape a step-up ladder is built to catch.

Credential Stuffing

Scoring the surge of replayed logins that step-up authentication slows after a leaked password list circulates.