> ## Documentation Index
> Fetch the complete documentation index at: https://docs.shieldlabs.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Step-up authentication (risk-based 2FA)

> 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.

## 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)](/features/risk-scoring) with the named [risk signals](/features/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](/features/high-risk-events#account-takeover), 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.

## Gate the login on the Risk Score

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.

## Build it

<Steps>
  <Step title="Create a ShieldLabs account and get your keys">
    [Start Free](https://app.shieldlabs.ai/) 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](/api/server-api), and each webhook endpoint has its own `whsec_…` signing secret. See [API keys](/setup/keys) and [Integration](/dashboard/integration).
  </Step>

  <Step title="Check the login as the form is filled in">
    Add the [snippet](/setup/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.

    ```html login.html theme={null}
    <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](/setup/snippet) covers the framework versions of the same pattern.
  </Step>

  <Step title="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](/use-case#the-shared-helpers); 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](/api/server-api) by `request_id` (read by `user_hid` to also pull the account's recent identifications). History API reads and the webhook are free.
  </Step>

  <Step title="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.

    ```js api/login.js theme={null}
    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`.

    <Frame caption="The Risk Score of one identification and each risk signal with its weight, in the analytics dashboard.">
      <img className="block dark:hidden" src="https://mintcdn.com/shieldlabs-725d18f1/JyleDzUFYU3SXP4Q/images/dashboard/identification-score-signals.png?fit=max&auto=format&n=JyleDzUFYU3SXP4Q&q=85&s=3afb420d98939a5a88c868d7a076c962" alt="The Risk Score gauge at 70.00, Dangerous, and the Risk signals table of one identification in the analytics dashboard: Anti-detect Browser with weight 60 and Proxy with weight 10." width="2238" height="440" data-path="images/dashboard/identification-score-signals.png" />

      <img className="hidden dark:block" src="https://mintcdn.com/shieldlabs-725d18f1/JyleDzUFYU3SXP4Q/images/dashboard/identification-score-signals-dark.png?fit=max&auto=format&n=JyleDzUFYU3SXP4Q&q=85&s=f8c03e36f9a68169c9a9710c004f1b2b" alt="The Risk Score gauge at 70.00, Dangerous, and the Risk signals table of one identification in the analytics dashboard in the dark theme: Anti-detect Browser with weight 60 and Proxy with weight 10." width="2238" height="440" data-path="images/dashboard/identification-score-signals-dark.png" />
    </Frame>
  </Step>

  <Step title="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.
  </Step>
</Steps>

## The threshold ladder, band by band

The three bands and their ranges are defined once in [Risk Scoring](/features/risk-scoring), and the cross-scenario action playbook lives in [Acting on results](/guides/acting-on-risk-score). 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.

## Signals worth weighting at login

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](/features/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`](/glossary#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`.

<Tip>
  Pair the score with the account. Read the account's recent identifications with a [History API](/api/server-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.
</Tip>

```bash Pull an account's recent identifications theme={null}
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 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](/features/high-risk-events#account-takeover), available in the analytics dashboard, the API and webhooks. History API reads on `account.shieldlabs.ai` are free.

## Honest caveat

<Warning>
  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.
</Warning>

## Test it

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.

## Next steps

<CardGroup cols={2}>
  <Card title="Acting on results" icon="code-branch" href="/guides/acting-on-risk-score">
    The full per-band decision playbook, including signal-aware decisioning and how to combine the score with specific signals.
  </Card>

  <Card title="Risk signals" icon="signal" href="/features/risk-signals">
    Every risk signal that can appear in `signals`, by slug, with its weight.
  </Card>

  <Card title="The Risk Score" icon="gauge" href="/features/risk-scoring">
    How the 0 to 100 score is built, what `signals` carries, and the band definitions.
  </Card>

  <Card title="Checkout and Payment Protection" icon="credit-card" href="/use-case/payment-fraud">
    The same approach applied to the payment step, where risk signals warrant a harder response.
  </Card>

  <Card title="Account Takeover" icon="user-lock" href="/use-case/account-takeover">
    Why a new Device ID and country on an established account is the shape a step-up ladder is built to catch.
  </Card>

  <Card title="Credential Stuffing" icon="key" href="/use-case/credential-stuffing">
    Scoring the surge of replayed logins that step-up authentication slows after a leaked password list circulates.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.