Learn how to trigger step-up authentication (risk-based 2FA) only when a login or the account behind it is risky.
Most logins to an account are routine. A few are not: a login arriving through Tor, an anti-detect browser, or a brand-new device in a new country for an existing account. This guide scores the login in real time and reads the account behind it, then your login flow walks a threshold ladder: who passes, who gets a second factor, and who gets your hardest verification path.
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.
ShieldLabs scores each login as a Risk Score (0-100) with the named risk signals behind it, and ties it to the account and the device. Three things drive the gate: the User HID (your hashed account id), the durable Device ID, and the login’s risk signals. The Device ID holds through cleared cookies, incognito and IP changes, so a familiar device stays familiar and a new one stands out even when someone resets everything visible in the browser. ShieldLabs returns the score and the signals on each identification and detects Account takeover on your users as a High-Risk Event, available in the analytics dashboard, the API and webhooks; you choose the action for each case (allow, require 2FA or hold for verification) in your login flow.
The login policy: read the identification’s risk_score and the weight of each named risk signal, 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: a Device ID this User HID has never used, a country it has never signed in from, or an account with an Account takeover event is a stronger step-up trigger than the score alone. The outcome: a familiar device with a Trusted score passes untouched, while a Suspicious or Dangerous score, an unfamiliar device or an account with that event steps up.
Start Free with 5,000 identifications, one time, no credit card, or log in. In the analytics dashboard, add the domain you want to protect under Integration > Domains, then open Integration > API keys and copy its keys with the copy button next to each. The Public Key loads the snippet in the browser. Keep the server credentials on your backend: the Private API Key reads the History API, and each webhook endpoint has its own whsec_… signing secret. See API keys and Integration.
2
Check the login as the form is filled in
Add the snippet to your login page and call forceCheckAnonymous for every login attempt, when the user starts filling the form (its first focus). It runs an identification every time, keeps the current Session ID and restarts the five-minute window, so you score the login as it is now; a plain checkAnonymous is skipped when the same browser was checked in the same visit within the last five minutes, and the login would reach your backend without a request ID. onInitialized fires when the check starts, before the snippet has sent the identification, so do not navigate away inside it: store the request ID and let the form submit normally. Pass the hashed User HID only after the password check succeeds, never a raw email or user id: forceCheckAuthenticatedUser on the first signed-in page, then checkAuthenticatedUser on every signed-in page. The account’s history then holds only its own signed-in activity; an attempt identified with the User HID before the password is checked would add the device of whoever typed the username to that account.
login.html
<script type="module"> const mod = await import( 'https://cdn.shieldlabs.ai/snippet.js?publicKey=YOUR_PUBLIC_KEY' ); const form = document.getElementById('login-form'); // The browser does NOT compute the Risk Score. onInitialized gives the // requestID, the join key to the webhook you receive server-side. It fires // when the check starts, before the snippet sends it, so start the fresh // identification when the form comes into use and let the form submit normally. // Every attempt is identified anonymously: the User HID comes after sign-in. const identify = () => { mod.forceCheckAnonymous({ onInitialized: (result) => { if (result.status === 'initialized') { document.getElementById('shieldlabs-request-id').value = result.requestID; } }, }); }; if (form.contains(document.activeElement)) identify(); // already focused (autofocus) else form.addEventListener('focusin', identify, { once: true });</script><form id="login-form" method="POST" action="/api/login"> <input type="hidden" id="shieldlabs-request-id" name="shieldlabsRequestId" /> <input type="text" name="username" placeholder="Username" /> <input type="password" name="password" placeholder="Password" /> <button type="submit">Sign in</button></form>
requestID from onInitialized is your join key to the webhook you receive server-side. Installing the snippet covers the framework versions of the same pattern.
3
Receive the webhook and cache it by request ID
ShieldLabs POSTs one webhook per 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, public_ip.country and user_hid you compare below all ride in data on the same webhook envelope. Delivery is at-most-once with no retries, so for a guaranteed read the helper falls back to the History API by request_id (read by user_hid to also pull the account’s recent identifications). History API reads and the webhook are free.
4
Walk your threshold ladder
Wait briefly for the Risk Score, then branch. The band is your starting point; the account and the signals array refine it. Below is a three-rung ladder you can tune to your own traffic. You choose the action for each rung (require 2FA, route to strong verification, hold and alert) and run it in your login flow; ShieldLabs returns the Risk Score and its named risk signals.
api/login.js
app.post('/api/login', async (req, res) => { const { username, password, shieldlabsRequestId } = 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; the helper falls back to the History API. const risk = await waitForScore(shieldlabsRequestId, 2000); // 3. The guard. No result is not the same as "clean": default to 2FA rather // than letting a login through on missing data. Every login attempt is // identified with forceCheckAnonymous, so the identification carries "anonymous". if (!risk) { return res.status(200).json({ status: 'require_2fa', reason: 'no_identification' }); } if (risk.user_hid !== 'anonymous') { return res.status(200).json({ status: 'require_2fa', reason: 'identification_mismatch' }); } if (risk.risk_score > 100 || risk.device_id === NIL_DEVICE) { // The 999 rate-limit marker, or no usable device signals. return res.status(200).json({ status: 'require_2fa', reason: 'unverified_device' }); } // 4. The account behind the login: its worst band over its signed-in history, // and the accounts with an Account takeover event, recorded in your own store // when it arrives through the API or webhooks or when you review it in the // analytics dashboard. // A failed History read is unverified: default to 2FA. const account = await accountView(user.hashedId).catch(() => null); if (!account) { return res.status(200).json({ status: 'require_2fa', reason: 'history_unavailable' }); } const watched = await takeoverWatchlist.has(user.hashedId); // The threshold ladder. Branch on the band, and on signals[].name slugs // when one signal matters on its own. The bands are a guide, not a rule. const loginBand = band(risk.risk_score); if (loginBand === 'Dangerous') { // Strong risk signals folded into the score. Require your strongest // factor, or hold and alert the account owner. await alertAccountOwner(user.id, risk); return res.status(200).json({ status: 'verify', method: 'strong' }); } if (loginBand === 'Suspicious' || watched || account.worstBand === 'Dangerous') { // One moderate signal or several overlapping, or a risky account. Require a second factor. return res.status(200).json({ status: 'require_2fa', method: 'otp' }); } // Trusted: 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 and maps it to the webhook field names. accountView reads the account’s identifications by user_hid.
The Risk Score of one identification and each risk signal with its weight, in the analytics dashboard.
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 three bands and their ranges are defined once in Risk Scoring, and the cross-scenario action playbook lives in Acting on results. The API returns only the number (risk_score on the webhook, score in History), so map it to a band in your backend. Mapped to a login gate, a sensible starting ladder is:
Band
Suggested login action
Trusted (0-29)
Issue the session, log the signals
Suspicious (30-59)
Require a second factor (OTP, authenticator)
Dangerous (60-100)
Route to your strongest verification, or hold and alert the account owner
A high-value account is a good place to draw the lines tighter.
The Risk Score already folds these in. To raise friction at a moderate score when a heavy signal is present, check the entry’s name slug or its weight. Each entry in signals is { name, weight } with a stable slug; the risk signals reference lists every slug with its weight. A slug can repeat with a partial weight when an earlier verdict is carried forward, so test for presence rather than counting entries.
Signal (signals[].name)
Weight
Why it matters at login
tor (Tor)
99
The connection exits through the Tor network. Rare for a legitimate sign-in; usually a strong-verification path.
javascript_disabled (JavaScript Disabled)
90
A headless or automated client. On its own it puts the login in the Dangerous band.
browser_automation (Browser Automation)
60
An automated browser driving the login, the bot signal behind credential stuffing.
antidetect_browser (Anti-detect Browser)
60
A browser built to spoof its fingerprint. A common shape behind credential-stuffing follow-up and account takeover.
os_mismatch (OS Mismatch)
60
The OS the browser claims does not match other evidence. A spoofing indicator.
abuser (Abuser Flag)
10
The IP has a record of abuse. Light on its own; weigh it with the rest of the signals.
vpn, privacy_relay (VPN, Privacy Relay)
15 each
Common for privacy-conscious customers. Weaker evidence on its own; 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.browser_automation and so on), so you can branch without inspecting the signals array. Note that the anti-detect slug is antidetect_browser while its flag key is anti_detect_browser.
Pair the score with the account. Read the account’s recent identifications with a History API read by user_hid: a Device ID or country it has never used, or a worst band of Dangerous in its recent history, is a stronger step-up trigger than the same score on a familiar device. When an Account takeover event arrives for a user through the API or webhooks, or when you review it in the analytics dashboard, add the user to a watchlist and step up every login for that account until reviewed.
The response is a { data, total } envelope; data is an array of identifications (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). The shared shieldlabsHistory helper returns this data array for you. Comparing the current device and country with the account’s history is the check at login; across the account’s activity, ShieldLabs detects Account takeover as a High-Risk Event, available in the analytics dashboard, the API and webhooks. History API reads on account.shieldlabs.ai are free.
Read a high Risk Score together with its named risk signals and the user’s history. A real customer on a corporate VPN can reach the Suspicious band; the signals show why, and you choose the action for each case. VPN and iCloud Private Relay add 15 each, so a real customer on a corporate VPN usually stays Trusted and reaches the Suspicious band only when another signal stacks on top. Requiring a second factor rather than a hard block on the upper rungs keeps those customers in while still slowing someone who only has the password.
Confirm the durable identity before you wire thresholds to real friction. Sign in once and note the device_id on the webhook, then reproduce the “new arrival” without a new device:
Clear cookies and storage, then sign in again. The cookie_id and visitor_id change, but the device_id stays the same.
Open an incognito or private window and sign in. The same device_id returns.
Switch networks (or turn on a VPN) so your IP changes, then sign in. The device_id holds; the signals array now also carries the masking signal (for example vpn), which raises the risk_score.
A different device, or a second browser on the same machine, returns a different device_id, which is the shape your ladder should step up on. This test proves a fresh cookie or a rotated IP cannot pass for a familiar device.