React bindings for ShieldLabs device intelligence: a provider that loads the ShieldLabs agent once, and hooks that return a request ID for every identification, with loading and error state.
@shieldlabs-ai/react is a thin layer over @shieldlabs-ai/js,
the browser loader that imports the hosted agent from https://cdn.shieldlabs.ai at runtime. It
supports React 18 and 19, renders on the server without touching browser globals, and loads the
agent once per app, also in StrictMode.
New to ShieldLabs? Start free, then copy the Public Key of your domain from Integration > API keys in the analytics dashboard (the Install tab also shows a ready snippet that contains it).
- Browser.
ShieldLabsProviderloads the agent, anduseIdentify()runs an identification for a protected action. The page receives arequestId. - Your backend. It receives the
requestIdwith the protected action (signup, login, checkout) and reads the verdict for it from the History API with a ShieldLabs server SDK, or receives it in a signedidentification.scoredwebhook. - Decision. Your backend acts on the Risk Score (bands: trusted 0-29, suspicious 30-59, dangerous 60-100), the detection flags and identifiers such as the device ID.
The browser only ever gets the request ID. The Risk Score, risk signals, detection flags, visitor ID and device ID are read on your server, with one of the server SDKs: Node.js, Python, Go, PHP, Java or .NET.
The identification.scored webhook is delivered once per identification today (1-second timeout,
no retries). Use the History API when you need a guaranteed read, and make webhook handlers
idempotent on data.request_id, because future retries will resend identical bytes.
npm install @shieldlabs-ai/react @shieldlabs-ai/js
# or
yarn add @shieldlabs-ai/react @shieldlabs-ai/js
# or
pnpm add @shieldlabs-ai/react @shieldlabs-ai/js@shieldlabs-ai/js (1.x) and react (18 or 19) are peer dependencies.
The snippets use Vite with TypeScript; other bundlers expose environment variables their own way.
Put the Public Key of your domain in .env (the value below is a placeholder):
# .env
VITE_SHIELDLABS_PUBLIC_KEY=0123456789abcdef0123456789abcdefRender ShieldLabsProvider once, near the root of your app, around the components that identify:
// main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { ShieldLabsProvider } from '@shieldlabs-ai/react';
import { SignupForm } from './SignupForm';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ShieldLabsProvider publicKey={import.meta.env.VITE_SHIELDLABS_PUBLIC_KEY}>
<SignupForm />
</ShieldLabsProvider>
</StrictMode>,
);Run an identification when the user submits a protected action, and send the requestId with it:
// SignupForm.tsx
import type { SyntheticEvent } from 'react';
import { useIdentify } from '@shieldlabs-ai/react';
export function SignupForm() {
const { identify, isLoading } = useIdentify();
async function onSubmit(event: SyntheticEvent<HTMLFormElement>) {
event.preventDefault();
const email = new FormData(event.currentTarget).get('email');
// null when there is no identification (the reason is in `error`). The signup goes out anyway,
// and your server treats it as unverified.
const result = await identify();
await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, requestId: result?.requestId ?? null }),
});
}
return (
<form onSubmit={onSubmit}>
<input name="email" type="email" required />
<button disabled={isLoading}>Sign up</button>
</form>
);
}identify() never rejects: it resolves the result, or null with the reason in error. A second
submit while the identification runs gets the same one, so a double click costs one identification.
On your server, read the verdict for requestId with a server SDK, for example
identifications.get(requestId) in @shieldlabs-ai/node,
which waits until the identification has been scored. The History row appears about 1-3 seconds
after identify() resolves and can be refined for up to about 10 seconds as follow-up checks
finish, so starting the identification when the user begins the action (see
Protect a form) gets your server the verdict sooner. Accept each request ID once
and only within your freshness window (the examples use 5 minutes): one identification authorizes
one protected action.
Keep the page alive after
identify()resolves. The agent posts the identification right after it hands over the request ID. Sending your request withfetch(), as above, keeps the page open. If you navigate right after the submit (a full-page form post or a redirect), start the identification early instead (see Protect a form).
Test on a registered domain. ShieldLabs records identifications only for the domains registered in your account. On
localhostthe page still receives arequestId, but the identification is rejected with401and your backend never finds it. Test on a development domain with its own keys, as described in Environments.
identify() on submit, as in the quick start, is enough for most single-page apps. To have the
identification finished by the time the user submits, start it on the first interaction with the
form. getAgent() from useShieldLabs() resolves the loaded agent of @shieldlabs-ai/js, and its
identifyOnInteraction(form) starts identify() on the first focusin, pointerdown or keydown
inside the form. The handle's take() returns that identification for this submission and re-arms,
so the next submission gets its own request ID:
import { useEffect, useRef, type SyntheticEvent } from 'react';
import { useIdentify, useShieldLabs, type InteractionIdentifier } from '@shieldlabs-ai/react';
export function SignupForm() {
const { getAgent } = useShieldLabs();
const { identify } = useIdentify();
const formRef = useRef<HTMLFormElement>(null);
const early = useRef<InteractionIdentifier | null>(null);
useEffect(() => {
const form = formRef.current;
if (!form) return;
let active = true;
getAgent().then(
(agent) => {
if (active) early.current = agent.identifyOnInteraction(form);
},
() => {}, // the agent could not load: the submit handler tries again
);
return () => {
active = false;
early.current?.dispose();
early.current = null;
};
}, [getAgent]);
async function onSubmit(event: SyntheticEvent<HTMLFormElement>) {
event.preventDefault();
const email = new FormData(event.currentTarget).get('email');
// The early identification while it is fresh, otherwise a new one. Without an early handle,
// identify() loads the agent again; it resolves null when there is no identification.
const result = early.current ? await early.current.take().catch(() => null) : await identify();
await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, requestId: result?.requestId ?? null }),
});
}
return (
<form ref={formRef} onSubmit={onSubmit}>
<input name="email" type="email" required />
<button>Sign up</button>
</form>
);
}For a classic full-page post, put the request ID in a hidden field
(<input type="hidden" name="requestId" /> in a <form method="post" action="/sitelet?url=https%3A%2F%2Fgithub.com%2Fsignup">) and submit
the form yourself:
async function onSubmit(event: SyntheticEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const result = await early.current?.take().catch(() => null);
(form.elements.namedItem('requestId') as HTMLInputElement).value = result?.requestId ?? '';
form.submit();
}Because the identification starts on the first interaction, it has normally finished posting by the
time the user submits. If users can submit without interacting first (for example autofill and a
single click on the button), prefer sending the form with fetch(), which keeps the page alive.
take() hands out the early identification only while it is fresh. When it failed, or finished more
than 4 minutes ago, take() starts a new one, so the request ID your server receives stays inside a
5-minute freshness window. While users keep interacting with the form, a new identification starts
at most every 4 minutes (after a failure, at most one attempt every 5 seconds), and each of them is
billed. The effect's cleanup removes the listeners when the form unmounts. With
autoLoad={false} (see Consent), getAgent() waits for load(), so the form is armed
once load() has been called and the agent has loaded.
examples/vite is a complete signup form built this way.
Pass a User HID so ShieldLabs ties the identification to the account. Compute it on your server
from your account ID with a secret key, for example with the userHid(userId, secret) helper of
the server SDKs (HMAC-SHA256, 64 hex characters), and hand it to the page, for example in your
session data:
const { identify } = useIdentify({ userId: session.userHid });
// Later, for the protected action. identify({ userId }) overrides the User HID for one call.
const result = await identify(); // result?.userId is the User HID that was sentOptions of identify() with a userId key override the User HID of the hook for that call, also
when the value is undefined or null: identify({ userId: undefined }) identifies anonymously.
Only options without the key use the User HID of the hook.
Never pass a raw email address, phone number or database ID. Omit userId for visitors who are not
signed in. The rules for the value (reserved values, characters that are hard to search) are in the
@shieldlabs-ai/js guide.
useIdentify({ runOnMount: true }) runs identify() once when the component mounts, as soon as the
agent is ready. isLoading is true from the first render. Re-renders, prop changes and StrictMode
do not run it again; a new mount of the component does. With autoLoad={false}, a mount before
load() ends at once with a not_initialized error and does not run again after load() (see
Consent). Every run is a billable identification, so use it for a component that is
itself the protected step, never in a layout, a list item or a component that mounts on every route.
result is one identification, and one identification authorizes one protected action: send its
requestId with a single request, within your freshness window (5 minutes in the examples). Your
server rejects a request ID it has seen before or one that is too old. For every later action (a
retry after a declined card, a second submit, a user who comes back after a break), call
identify() again. For form submits, identify() in the submit handler, as in the quick start, is
the simpler choice.
function RecoveryStep({ userHid }: { userHid: string }) {
const { result, isLoading } = useIdentify({ userId: userHid, runOnMount: true });
if (isLoading) return <p>Loading</p>;
// The request ID goes with the one request that loads the recovery options. Without a result (the
// identification failed), that request is sent without a requestId.
return <RecoveryOptions requestId={result?.requestId ?? null} />;
}checkOnLoad runs check() once per provider mount when the agent is ready, for passive monitoring
of the visit. true checks anonymously, { userId } passes a User HID:
<ShieldLabsProvider publicKey={publicKey} checkOnLoad={session ? { userId: session.userHid } : true}>
<App />
</ShieldLabsProvider>The agent limits check() to one identification per visit every five minutes, shared across tabs.
The check uses the value of checkOnLoad at the moment the agent becomes ready, and changes after
that do not run it again. It is skipped when an identify() or check() for the same User HID is
still running at that moment (for example runOnMount, or a submit made while the agent loaded):
that call already identifies the visit, and the agent runs one identification at a time for a User
HID. A skipped or failed check is ignored (invalid options log a console warning). Your backend sees
these identifications like any other; to get the request ID in the page, call check() from
useShieldLabs() instead, which resolves null when the agent skipped it.
useShieldLabs() returns the agent status, the provider's identify() and check(), load()
and getAgent():
function AgentStatus() {
const { status, error } = useShieldLabs();
if (status === 'error') return <small>Identification is unavailable ({error?.code}).</small>;
return null;
}You do not need to wait for 'ready': identify() and check() called while the agent loads wait
for it. The call's timeout (default: the provider timeout, 10 seconds) covers the whole call:
that wait and then the agent's answer, which gets only the time that is left. Never block a
protected action on the status. When the agent cannot load (a content blocker, a network error),
send the action without a requestId; your backend treats it as unverified. A failed or timed-out
load is tried again by the next identify(), check(), getAgent() or load(), and the status
follows. When the provider props are invalid (for example a missing publicKey because an
environment variable is not set) or the page is not a secure context, the provider also logs a
console warning that starts with [ShieldLabs].
identify() and check() of useShieldLabs() are the calls of @shieldlabs-ai/js: identify()
rejects with a ShieldLabsError when there is no identification. useIdentify() wraps it with
result, isLoading and error, never rejects and shares a running call.
- The provider and the hooks render on the server. Nothing touches
windowordocumentduring render; the agent loads in an effect after hydration (withautoLoad={false}, onceload()is called). The server HTML showsstatus: 'loading'(andisLoading: trueforrunOnMount). - In StrictMode the provider loads the agent once, and
runOnMountandcheckOnLoadrun once per mount.load()of@shieldlabs-ai/jsis memoized per agent URL and Public Key, so mounting the provider again reuses the agent that is already loaded. - The hooks never identify on re-renders or on route changes. Keep the provider above your router so that navigation does not remount it.
- Inside
<Activity mode="hidden">(React 19.2 and later) the provider and the hooks keep their state. A load or an identification that finishes while the content is hidden shows up when it is visible again, and showing it again does not runrunOnMountorcheckOnLoadagain. - The built files start with the
"use client"directive, so bundlers for React Server Components treat the package as client code.
Use @shieldlabs-ai/next. It provides this
provider and these hooks as a client module for the App Router (the Pages Router is documented
there) and adds server helpers in @shieldlabs-ai/next/server for reading identifications and
verifying webhooks in route handlers.
The rules of @shieldlabs-ai/js apply unchanged:
- Call budget: one identification per protected action, and a small per-IP budget on the ingest. Never clear the agent's storage.
- Content Security Policy:
the
script-srcandconnect-srcorigins the agent needs.
The agent does not read your consent banner (see
Consent in the @shieldlabs-ai/js guide).
Where your policy requires consent before the agent loads, render the provider with
autoLoad={false}: nothing loads until load() from useShieldLabs() is called, or until
autoLoad becomes true.
import { ShieldLabsProvider, useShieldLabs } from '@shieldlabs-ai/react';
import { SignupForm } from './SignupForm';
export function App() {
return (
<ShieldLabsProvider publicKey={import.meta.env.VITE_SHIELDLABS_PUBLIC_KEY} autoLoad={false}>
<ConsentBanner />
<SignupForm />
</ShieldLabsProvider>
);
}
function ConsentBanner() {
const { load } = useShieldLabs();
// Record the choice as your consent tool requires, then load the agent.
return <button onClick={load}>Accept</button>;
}When your consent state lives in React already, pass it instead:
<ShieldLabsProvider publicKey={publicKey} autoLoad={consentGiven}>. Once loading has started,
calls wait for the agent as usual.
Until then, the rest of the app works unchanged and nothing waits for consent:
identify()fromuseIdentify()resolvesnullat once, with anot_initializederror, so forms go out without arequestIdand your server treats them as unverified.runOnMountends the same way and does not run again by itself afterload().useShieldLabs().identify()rejects withnot_initialized, andcheck()resolvesnull.getAgent()waits, with no timeout of its own, so a form set up for early identification (see Protect a form) is armed onceload()has been called and the agent has loaded. When that load fails,getAgent()rejects with its error.checkOnLoadruns once the agent is ready.statusstays'loading'.
Setting autoLoad back to false does not unload an agent that has loaded.
| Export | Description |
|---|---|
ShieldLabsProvider |
Loads the agent once and provides it to the hooks below it |
useShieldLabs() |
Agent status, load error, identify(), check(), load() and getAgent() of the closest provider |
useIdentify(options?) |
Identification with result, isLoading, error and reset(). Its identify() resolves null instead of rejecting |
ShieldLabsError |
The error class of @shieldlabs-ai/js (re-exported). Has code and optional cause |
| Types | ShieldLabsProviderProps, ShieldLabsStatus, UseShieldLabsResult, UseIdentifyOptions, UseIdentifyResult, and from @shieldlabs-ai/js: IdentifyOptions, IdentifyResult, InteractionIdentifier, LoadOptions, ShieldLabsAgent, ShieldLabsErrorCode |
<ShieldLabsProvider> props
| Prop | Type | Default | Description |
|---|---|---|---|
publicKey |
string |
required | Public Key of your domain |
environment |
'production' | 'development' |
'production' |
Which ShieldLabs CDN to load the agent from |
scriptUrl |
string |
Advanced: agent module URL override (https, or http on localhost and 127.0.0.1) |
|
timeout |
number |
10000 |
Milliseconds to wait for the agent to load, and the default timeout of each identify() and check() call |
autoLoad |
boolean |
true |
Loads the agent after the first render. With false, nothing loads until load() is called or autoLoad becomes true (see Consent) |
checkOnLoad |
boolean | { userId?: string } |
false |
Runs check() once per provider mount when the agent is ready, unless a call for the same User HID is running |
children |
ReactNode |
Your app |
Changing publicKey, environment, scriptUrl or timeout loads the agent for the new options
(once loading is allowed), and the status goes back to 'loading'.
useShieldLabs() returns
| Field | Type | Description |
|---|---|---|
status |
'loading' | 'ready' | 'error' |
'loading' until the agent has loaded, then 'ready', or 'error' when loading failed |
error |
ShieldLabsError | null |
Why loading failed while status is 'error' |
identify(options?) |
Promise<IdentifyResult> |
Fresh identification, a new request ID on every call. Waits for the agent while it loads. Rejects with a ShieldLabsError |
check(options?) |
Promise<IdentifyResult | null> |
Background check, limited by the agent to one per visit every five minutes. null when skipped |
load() |
void |
Starts loading the agent: needed only with autoLoad={false}. Also loads again after a failed load. Safe to call more than once; call it from an event handler or an effect |
getAgent() |
Promise<ShieldLabsAgent> |
The loaded agent of @shieldlabs-ai/js, for example for identifyOnInteraction(form). Waits while the agent loads (with autoLoad={false}, until load()), with no timeout of its own. Rejects with the load error |
useIdentify(options?)
| Option | Type | Default | Description |
|---|---|---|---|
userId |
string |
User HID for every identification of this hook. Options of identify() with a userId key override it, also with undefined or null (an anonymous identification) |
|
runOnMount |
boolean |
false |
Runs identify() once when the component mounts, as soon as the agent is ready |
| Field | Type | Description |
|---|---|---|
identify(options?) |
Promise<IdentifyResult | null> |
Starts a new identification and resolves its result, or null when there is none (the reason is in error). Never rejects. While a call of this hook with the same User HID and timeout runs, returns that call instead of starting another (a call without timeout counts as one with the provider timeout) |
result |
IdentifyResult | null |
Result of the latest identification. null while a new one runs, when it failed and after reset() |
isLoading |
boolean |
true while the latest identification runs |
error |
ShieldLabsError | null |
Why the latest identification failed |
reset() |
void |
Clears result and error. A running identification no longer updates the state, and the next identify() starts a new one |
The state follows the call made last. A call with another User HID or timeout than a running one
starts its own identification. The User HID of a call is the userId of its options when they have
that key (undefined and null both mean anonymous), else the userId of the hook: in a hook with
a User HID, identify() and identify({ userId: undefined }) are two identifications. A call
without timeout counts as one with the provider timeout (10 seconds by default), so identify()
and identify({ timeout: 10000 }) share one identification. Two useIdentify() hooks never share
a call. The functions keep their identity across renders (identify changes when userId
changes), so they are safe in effect dependencies.
IdentifyOptions (from @shieldlabs-ai/js)
| Option | Type | Description |
|---|---|---|
userId |
string |
User HID computed on your server. Omit for anonymous checks |
timeout |
number |
Milliseconds the whole call may take: a wait for the agent to load, then the agent's answer in the time that is left. Overrides the provider timeout, which also limits the load itself |
IdentifyResult (from @shieldlabs-ai/js)
| Field | Type | Description |
|---|---|---|
requestId |
string |
Send it to your backend with the protected action |
userId |
string | null |
The User HID used, null for anonymous checks |
Every error is a ShieldLabsError. useIdentify().identify() stores it in error and resolves
null; the calls of useShieldLabs() reject with it. Branch on error.code:
code |
When | What happens and what to do |
|---|---|---|
invalid_options |
A provider prop or a call option failed validation, or publicKey holds a server-side secret |
A bad prop sets status to 'error' and logs a console warning; a bad call option fails that call. Fix the value; retrying does not help |
unsupported_environment |
The page is not a secure context | status is 'error', with a console warning. Serve the page over HTTPS (localhost and 127.0.0.1 also work over http) |
load_failed |
The agent module could not be imported (network error, content blocker, Content Security Policy) | status is 'error'. Continue without an identification; the next identify(), check(), getAgent() or load() loads again |
timeout |
The agent did not load, or an agent call did not answer, within the timeout (default 10 seconds) | Continue without an identification. A load that timed out keeps running, and the next call uses it once it arrives |
not_initialized |
identify() only: the agent did not start an identification, for example because another one is running in this or another tab, or the provider has autoLoad={false} and load() has not been called. check() resolves null instead |
Retry once later, or continue without an identification |
Whenever there is no identification, send the protected action anyway without a requestId: your
backend treats a missing identification as unverified (for example step-up or review), never as
clean. Calling a hook outside ShieldLabsProvider throws an Error that names the hook.
- React 18 and 19 (React DOM), with TypeScript types for both.
- Browsers: the same as
@shieldlabs-ai/js(ES modules, dynamicimport()and WebCrypto, in a secure context). - Server rendering with
react-dom/serverin Node.js 18 or later; the agent loads only in the browser. - Output: ES2019 syntax as ESM and CommonJS with TypeScript declarations, marked
"use client". No dependencies besides the peer dependencies.
npm ci
# Until @shieldlabs-ai/js is on npm, install a local pack of it (see CONTRIBUTING.md):
npm install --no-save ../shieldlabs-js/shieldlabs-ai-js-1.0.0.tgz
npm run typecheck
npm run lint
npm test -- --coverage # builds first, then runs the tests
npm run buildSee CONTRIBUTING.md. Documentation: https://docs.shieldlabs.ai. Analytics dashboard: https://app.shieldlabs.ai. Support: contact@shieldlabs.ai.
MIT, Copyright (c) 2026 ShieldLabs Inc.