Le nguqulelo yenziwe ngumatshini yaye isalinde ukuphononongwa.Tshintshela esiNgesini
Kweli phepha

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.

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 the read:device scope, 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

  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.
FieldRule
Name1 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 pathA 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 eventThe 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 fieldRule
requestIdRequired. The UUID that the browser received.
actionRequired. The action name. An id minted for login cannot satisfy a checkout check.
expectedDomainOptional. 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.

StatusBodyMeaning
200ok: true and the fields aboveRedeemed now. The id is spent.
400invalid_body, invalid_request_id, invalid_action or invalid_expected_domainThe request is malformed.
401The standard API-key errorThe key is missing or invalid.
403The key lacks the read:device scopeUse a key with that scope.
404request_not_foundThe id is unknown, older than 300 seconds, or belongs to another tenant.
409already_consumed, with consumed: true and consumedAtA replay inside the window. Refuse.
413payload_too_largeThe body is over 2048 bytes.
422action_mismatch or domain_mismatchThe action or the hostname differs. The id is not spent; fix the call.
429Rate limit exceeded, with a Retry-After headerSlow down. See Rate limits.
500Internal server errorTreat 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.

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

SymptomCauseFix
protect() resolves error: 'undeclared_protected_action'The name is not declared for the domain, or the page still holds an older tokenDeclare it in Script & Consent → Protected actions and reload.
data-nox-protect does nothingThe domain has no declared action, so the core installs no triggerDeclare the action with the same name.
A native form submits without nox_request_idThe 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 handlerThe handler read it after an awaitRead form.dataset.noxRequestId and call protect before any await.
Every page view runs a checkA catch-all path such as /**Narrow the path to the protected step.
404 request_not_foundThe id is older than 300 seconds, belongs to another tenant, or is not a protected-action idRedeem it right after the protected request, with a key of the same tenant.
409 already_consumedThe id was redeemed before, or the form posted twiceRefuse the request and run a fresh check for the next attempt.
422 action_mismatch or domain_mismatchThe action or expectedDomain differs from the browser runSend the same action name and the hostname of the page that ran it.
403 from the verify callThe key lacks the read:device scopeUse a key with that scope.
The Backoffice says the setting is not available on the workspace’s database version yetThe setting is not enabled for your organisation. Nothing was written.Try again later or contact your account team.