Protected actions: client trigger and server check
A protected action is a step an attacker targets, such as login, sign-up, checkout or identity verification. This page shows how to declare one, how the browser starts its check, and how your backend redeems the result. You need this when the domain is in a consent-gated mode and you still want a device check on those steps.
Key points
- Minimal check, declared actionsA declared protected action runs a minimal device check that stores nothing on the device, even without consent. Undeclared pages are not exempt.
- Single-use id, 300-second windowThe browser receives a requestId. Your backend redeems it once with POST /v1/protected-actions/verify within 300 seconds.
- No id means no client signalA timeout or blocked script leaves the form without nox_request_id. Treat that as no client signal and apply your own step-up, never as a pass.
- Server id is authoritativeUse the fingerprintId from the verify response as the device id for the action. A device id sent by the browser is a hint.
What it is
Availability: Evaluation access. Availability depends on your plan, role and the modules enabled for your organisation.
When a declared action runs, the core script runs a minimal device check even if the visitor has not consented. The check reads device and browser characteristics, stores nothing on the device, and runs no behavioral, WebRTC, geolocation, replay or analytics collection. The submission carries the reason protected_action:<name>. Actions you do not declare are not exempt: an ordinary page view never runs the page fingerprint without consent in the api and cookie modes.
The browser receives a requestId. Your backend redeems that id with POST /v1/protected-actions/verify and learns what the collector decided. The id is single-use, and it is valid for 300 seconds. A missing id is “no client signal”, not a pass. Whether a legal basis covers this check is your assessment; see Consent modes.
Prerequisites
- A domain with the core tag and
data-auto-init. See Install and Browser Runtime. - Workspace admin access to Backoffice → Integrations & API → Script & Consent. Saving the list needs a step-up MFA confirmation.
- A Server API key (
sk_prod_…) that carries theread:devicescope, kept on your server. See Authentication. - A backend route that receives the request id together with the protected request, for example the login post.
- Up to 16 protected actions per domain.
Steps
1. Declare the actions
- Open Backoffice → Integrations & API → Script & Consent and pick the domain.
- Open the Protected actions tab.
- Select Add protected action for each action. Fill Name, Match by and Pattern.
- Check the preview Resulting token configuration, then select Save script & consent settings and confirm the MFA prompt.
| Field | Rule |
|---|---|
| Name | 1 to 32 characters: lowercase letters, digits, - or _, starting with a letter. Must differ from every other name in the domain. Suggested: login, signup, checkout, kyc, password_reset, payment_method_add, withdrawal. |
Match by → Page path | A path glob that starts with /, up to 256 characters. * matches within one segment, ** across segments. No spaces, no ? or # (the query string is ignored), no ***. Example: /checkout/**. |
Match by → Form or button (CSS selector) | A selector of a form (the check runs on submit) or a button (on click), up to 256 characters, no control characters. Example: form#login. |
Match by → Page event | The name of a CustomEvent your page dispatches on window: 1 to 64 letters, digits, ., :, - or _. A digit may come first. |
The Backoffice warns, without blocking the save, when a path matches every page (/**, /*). The check would then run on every page view without consent. Use the path of the step you protect.
A path match runs when the page loads and again on client-side navigation, because the core watches the History API. Changes reach pages with their next session token.
2. Start the check from the page
A form that navigates. Add the action name to the form. The check starts when the visitor focuses or presses inside the form, so it has usually finished at submit. A run that settled less than 30 seconds earlier is reused.
<form id="login" data-nox-protect="login" method="post" action="/session">
<!-- fields -->
</form>
On a native submit the core holds the submit for up to 6 seconds, adds a hidden field nox_request_id, and submits again. data-nox-protect-timeout="<ms>" on the form changes the 6 seconds. If the check is slow or fails, the form goes without the id; the visitor is never blocked.
A button, a route change or code. Call protect and read the id:
try {
const result = await window.noxticaClient.protect('checkout', { timeoutMs: 6000 });
if (result.success) sendWithOrder(result.requestId);
else sendWithOrder(null); // timedOut, undeclared_protected_action, client_destroyed: no client signal
} catch (error) {
sendWithOrder(null); // the check itself failed: no client signal
}
protect rejects with a TypeError when the name is not a valid action name. An explicit call starts a fresh check unless one for the same action is already running. The result is { success, requestId, reason: 'protected_action:<name>', … } on success, and { success: false, timedOut: true, reason } when timeoutMs elapsed.
A form that your script submits (React, any fetch flow). Your handler calls preventDefault(), so a hidden field cannot reach the request. A capture-phase listener starts the check before your handler and publishes the result on the form as form.dataset.noxRequestId. A bubbling noxtica:protected event fires on the form when the check settles. Read the id synchronously at the top of the handler, and call protect synchronously to join a check that is still running:
async function onSubmit(event) {
event.preventDefault();
const form = event.currentTarget;
// Both lines run before the initial await: the id is withdrawn when this handler returns.
const ready = form.dataset.noxRequestId;
const pending = ready ? null : window.noxticaClient.protect('login', { timeoutMs: 6000 });
const requestId = ready ?? (await pending).requestId; // undefined on timeout or a blocked script
await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Request-Id': requestId ?? '' },
body: JSON.stringify(credentials),
});
}
The id is scoped to one attempt because it is single-use on the server. After your handler returns, the id is removed, and a retry runs a fresh check.
A page event. Dispatch the event that you declared, then call protect for the same name to receive the id:
window.dispatchEvent(new CustomEvent('app:signup-submit'));
3. Send the request id to your backend
Send the id with the protected request: the hidden nox_request_id field of a native form, or a header or body field that you choose in a script-submitted flow. The backend, not the browser, redeems it. Never put a Server API key in browser code.
4. Redeem the id on your server
curl -X POST "https://collect.noxtica.com/v1/protected-actions/verify" \
-H "Authorization: Bearer sk_prod_your_api_key" \
-H "Content-Type: application/json" \
-d '{"requestId":"<uuid from the browser>","action":"login","expectedDomain":"www.example.com"}'
| Body field | Rule |
|---|---|
requestId | Required. The UUID that the browser received. |
action | Required. The action name. An id minted for login cannot satisfy a checkout check. |
expectedDomain | Optional. A hostname, origin or URL of the page that ran the action, up to 269 characters. The check compares hostnames. |
The body is limited to 2048 bytes. A 200 response carries:
{
"ok": true,
"requestId": "<uuid>",
"action": "login",
"fingerprintId": "<device id>",
"verdict": "low",
"score": 12,
"confidence": 0.9,
"domain": "www.example.com",
"issuedAt": "2026-10-27T10:00:00.000Z",
"expiresAt": "2026-10-27T10:05:00.000Z",
"consumed": true,
"consumedAt": "2026-10-27T10:00:02.000Z"
}
verdict is one of minimal, low, medium, high or critical. The values above are an illustration of the shape, not a typical result.
| Status | Body | Meaning |
|---|---|---|
200 | ok: true and the fields above | Redeemed now. The id is spent. |
400 | invalid_body, invalid_request_id, invalid_action or invalid_expected_domain | The request is malformed. |
401 | The standard API-key error | The key is missing or invalid. |
403 | The key lacks the read:device scope | Use a key with that scope. |
404 | request_not_found | The id is unknown, older than 300 seconds, or belongs to another tenant. |
409 | already_consumed, with consumed: true and consumedAt | A replay inside the window. Refuse. |
413 | payload_too_large | The body is over 2048 bytes. |
422 | action_mismatch or domain_mismatch | The action or the hostname differs. The id is not spent; fix the call. |
429 | Rate limit exceeded, with a Retry-After header | Slow down. See Rate limits. |
500 | Internal server error | Treat it as no client signal. |
Error bodies from this route look like { "ok": false, "error": "<code>", "requestId": "<request id>" }. For the shared error format, see Errors.
A server example:
// Runs on your server. The key never goes to the browser.
async function verifyProtectedAction(requestId, action, expectedDomain) {
const response = await fetch('https://collect.noxtica.com/v1/protected-actions/verify', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.NOXTICA_SERVER_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId, action, expectedDomain }),
signal: AbortSignal.timeout(3000),
});
const body = await response.json().catch(() => ({}));
return { status: response.status, ...body };
}
const result = await verifyProtectedAction(req.body.nox_request_id, 'login', 'www.example.com');
if (result.status === 200 && result.ok === true) {
// result.fingerprintId is the authoritative device id for this action.
// result.verdict and result.score are signals; you decide what to do with them.
} else {
// 404, 409, 422, 5xx or a missing id: no client signal. Apply your own step-up. Never treat it as a pass.
}
Use the fingerprintId from this response as the device id for the action. A device id that the browser sends in a header or in an event is a hint. The server SDKs have no wrapper for this call today; use plain HTTP as above (see SDK family and availability). The full contract is in the API reference.
5. Find the runs in Backoffice
Open Backoffice → Fingerprints, open a device, and open its Events tab. A protected-action submission shows the badge Protected action: login. A run after consent shows Consented. Every change to the list is in the audit log.
Declare the actions
In Script & Consent → Protected actions add a name and a match by page path, form or button selector, or page event. Up to 16 per domain.
Start the check from the page
Add data-nox-protect to a form, call protect() from code, or read form.dataset.noxRequestId in a script-submitted handler.
Send the request id to your backend
Carry nox_request_id with the protected request. The backend, never the browser, redeems it.
Redeem the id on your server
Call POST /v1/protected-actions/verify with a key that has the read:device scope. 409 means the id was already used.
Find the runs in Backoffice
Fingerprints → device → Events shows the badge Protected action with the action name.
Verify it works
- Open the page with the banner unanswered in a gated mode. In the console run
await window.noxticaClient.protect('login'). The result hassuccess: true, arequestIdandreason: 'protected_action:login'. - Send that id to the verify call within 300 seconds. You get
200withconsumed: trueand afingerprintId. - Send the same call again. You get
409withalready_consumed. - Send the call with another
action. You get422withaction_mismatch, and the original id still works. - In Fingerprints → device → Events, the submission shows Protected action: login.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
protect() resolves error: 'undeclared_protected_action' | The name is not declared for the domain, or the page still holds an older token | Declare it in Script & Consent → Protected actions and reload. |
data-nox-protect does nothing | The domain has no declared action, so the core installs no trigger | Declare the action with the same name. |
A native form submits without nox_request_id | The check took longer than the hold time, or the script was blocked. The form is released by design. | Treat a missing id as no client signal. Raise data-nox-protect-timeout if needed. |
| The id is missing inside a script-submitted handler | The handler read it after an await | Read form.dataset.noxRequestId and call protect before any await. |
| Every page view runs a check | A catch-all path such as /** | Narrow the path to the protected step. |
404 request_not_found | The id is older than 300 seconds, belongs to another tenant, or is not a protected-action id | Redeem it right after the protected request, with a key of the same tenant. |
409 already_consumed | The id was redeemed before, or the form posted twice | Refuse the request and run a fresh check for the next attempt. |
422 action_mismatch or domain_mismatch | The action or expectedDomain differs from the browser run | Send the same action name and the hostname of the page that ran it. |
403 from the verify call | The key lacks the read:device scope | Use a key with that scope. |
| The Backoffice says the setting is not available on the workspace’s database version yet | The setting is not enabled for your organisation. Nothing was written. | Try again later or contact your account team. |
Related
- Consent modes, GPC and Google Consent Mode: why protected actions exist and how they differ from consent.
- Client API, events and the command queue:
protect()and thenoxtica:protectedevent. - Single-page apps, frameworks and tag managers: route changes and framework forms.
- Backend Integration: device lookup from your server.
- Authentication: Server API keys and scopes.
- Errors and Rate limits: shared API behaviour.
- Browser SDK troubleshooting: symptoms across the SDK.