> ## 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): score risky logins and require a second factor only when the session looks anonymous.

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)](/features/risk-scoring) with the [anonymity signals](/features/anonymity-signals) behind it, and resolves the session to a set of [identifiers](/features/identification). 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

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

  <Step title="Check the session at the login page">
    Add the [snippet](/setup/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.

    ```html login.html theme={null}
    <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](/setup/snippet) covers the React, Vue, Angular, Preact, and Svelte versions of the same pattern.
  </Step>

  <Step title="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](/use-case); 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](/api/server-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.
  </Step>

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

    ```js api/login.js theme={null}
    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`.
  </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 four bands and their ranges are defined once in [Risk Scoring](/features/risk-scoring), and the cross-scenario action playbook lives in [Acting on the Risk Score](/guides/acting-on-risk-score). Mapped to a login gate, a sensible starting ladder is:

| Band               | Suggested 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](/features/anonymity-signals) reference lists the full set with what each one means.

| Signal in `signals`         | Why it matters at login                                                                                                                                                    |
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Tor**                     | Connection exits through the Tor network. Rare for a legitimate sign-in. Usually a strong-verification path decided by you.                                                |
| **JavaScript Disabled**     | The 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 Browser**     | Fingerprint-spoofing indicators. A common shape behind credential-stuffing follow-up and account takeover.                                                                 |
| **Abuser Flag**             | IP or device on an abuse reputation list. Treat as high risk even at a moderate score.                                                                                     |
| **OS Mismatch**             | The OS the browser claims does not match other evidence. A spoofing indicator.                                                                                             |
| **VPN** / **Privacy Relay** | Common 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`](/glossary#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.

<Tip>
  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](/api/server-api) read by `user_hid` to compare the current device and country against its history.
</Tip>

```bash Pull an account's recent sessions 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 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](/features/patterns). 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

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

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

<CardGroup cols={2}>
  <Card title="Acting on the Risk Score" 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="Signals" icon="signal" href="/features/anonymity-signals">
    Every signal that can appear in `signals`, in plain language, 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 pattern applied to the payment step, where anonymity signals warrant a harder response.
  </Card>

  <Card title="Account Takeover" icon="user-lock" href="/use-case/account-takeover">
    Why a new DeviceID 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>
