---
title: "Protected actions: client trigger and server check"
description: "Declare protected actions per domain, trigger the minimal check from a form or code, carry the request id to your backend and redeem it with the verify call."
canonical_url: "https://noxtica.com/docs/sdk/protected-actions/"
markdown_url: "https://noxtica.com/docs/sdk/protected-actions/index.md"
last_updated: 2026-10-27
published: 2026-10-27
section: "browser-sdk"
section_title: "Browser SDK"
site: "Noxtica"
language: "en"
source: markdown
---

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

## 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](https://noxtica.com/docs/sdk/consent/).

## Prerequisites

- A domain with the core tag and `data-auto-init`. See [Install and Browser Runtime](https://noxtica.com/docs/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 the `read:device` scope, kept on your server. See [Authentication](https://noxtica.com/docs/api/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

1. Open Backoffice → Integrations & API → Script & Consent and pick the domain.
2. Open the **Protected actions** tab.
3. Select **Add protected action** for each action. Fill **Name**, **Match by** and **Pattern**.
4. 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.

```html
<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:

```javascript
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:

```javascript
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:

```javascript
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

```bash
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:

```json
{
  "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](https://noxtica.com/docs/api/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](https://noxtica.com/docs/api/errors/).

A server example:

```javascript
// 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](https://noxtica.com/docs/sdk/family/)). The full contract is in the [API reference](https://noxtica.com/docs/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.

## Verify it works

1. Open the page with the banner unanswered in a gated mode. In the console run `await window.noxticaClient.protect('login')`. The result has `success: true`, a `requestId` and `reason: 'protected_action:login'`.
2. Send that id to the verify call within 300 seconds. You get `200` with `consumed: true` and a `fingerprintId`.
3. Send the same call again. You get `409` with `already_consumed`.
4. Send the call with another `action`. You get `422` with `action_mismatch`, and the original id still works.
5. 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](https://noxtica.com/docs/sdk/consent/): why protected actions exist and how they differ from consent.
- [Client API, events and the command queue](https://noxtica.com/docs/sdk/client-api/): `protect()` and the `noxtica:protected` event.
- [Single-page apps, frameworks and tag managers](https://noxtica.com/docs/sdk/single-page-apps/): route changes and framework forms.
- [Backend Integration](https://noxtica.com/docs/backend-integration/): device lookup from your server.
- [Authentication](https://noxtica.com/docs/api/authentication/): Server API keys and scopes.
- [Errors](https://noxtica.com/docs/api/errors/) and [Rate limits](https://noxtica.com/docs/api/rate-limits/): shared API behaviour.
- [Browser SDK troubleshooting](https://noxtica.com/docs/sdk/troubleshooting/): symptoms across the SDK.
