Centinel AnalyticaCentinel Analytica

Validation

Wire the /validate API into your backend when no platform integration covers your stack.

Overview

If a platform integration covers your stack, use it. This page is for custom backends and platforms not yet supported. You will call POST /validate for every protected request and act on the response.

A block or allow can come straight from a policy rule, including the crawler allowlist, before any bot scoring runs. Either way, your backend handles the decision the same.

When to call /validate

  • On the server, after you have parsed the request.
  • Once per request, before your application handles it.
  • Skip locally for paths you do not want to protect. The validator returns allow for those anyway, but skipping saves a round trip.

Send the request to POST https://validator.centinelanalytica.com/validate with content-type: application/json and your secret key in the x-api-key header. Only url and ip are required. ip must be a single parseable IP address, and cookie must be the bare _centinel UUID rather than the whole Cookie header.

Use a timeout of 300ms to 500ms. The validator's own budget is 500ms, so a longer client timeout turns a slow validator into a stall your visitors feel.

Handle each decision

Call POST /validate with the request URL, method, client IP, all headers, and the _centinel cookie.

Apply every entry from response.headers to your outgoing response, and every entry from response.cookies as a Set-Cookie.

Send response.status_code as the HTTP status, falling back to 403 if it is missing.

For block or redirect, base64-decode response.response_html and return it as the response body.

For allow, return validator-owned HTML when response_html is present. Otherwise, pass the request to your application.

Failing safely

Fail open on a 5xx or a transport error, and fail closed on a 4xx. A 400, 401, or 409 is a deterministic rejection, not an outage. Treating one as a reason to allow the request is how bots get through: a crafted ip value makes the validator answer 400, so a backend that allows on 400 hands every attacker a bypass.

Do not infer challenge intent from the decision string alone. A custom block page can arrive as decision: "redirect". Serve the response HTML and status that the validator returns.

type ValidateDecision = 'allow' | 'block' | 'redirect';

async function enforce(req, res, next) {
  let result;
  try {
    result = await callValidate({
      url: req.url,
      method: req.method,
      ip: clientIpFrom(req),
      referrer: req.headers.referer,
      cookie: req.cookies['_centinel'],
      headers: req.headers,
    }); // 300-500ms timeout
  } catch {
    return next(); // transport error or timeout: fail open
  }

  if (result.status >= 500) return next(); // validator outage: fail open
  if (result.status >= 400) return res.status(403).send('Forbidden'); // rejected: fail closed
  if (result.body.success === false) return next();

  const data = result.body;
  for (const [name, value] of Object.entries(data.headers ?? {})) res.setHeader(name, value);
  for (const c of data.cookies ?? []) {
    res.cookie(c.name, c.value, {
      path: c.path,
      domain: c.domain || undefined,
      secure: c.secure,
      sameSite: c.same_site,
      maxAge: c.max_age * 1000,
    });
  }
  if (data.rate_limited) res.setHeader('Retry-After', String(data.retry_after));

  const status = data.status_code || 403;
  const html = data.response_html
    ? Buffer.from(data.response_html, 'base64').toString('utf8')
    : null;

  if (data.decision === 'allow' && html !== null) {
    return res.status(status).send(html); // validator-owned response, such as robots.txt
  }

  switch (data.decision) {
    case 'allow':
      return next();
    case 'block':
    case 'redirect':
      // Missing HTML can result from a rendering or session-encryption failure.
      return res.status(status).send(html ?? 'Access Denied');
    default:
      return res.status(403).send('Access Denied');
  }
}

Preserve every cookie attribute the validator sends. Dropping max_age demotes _centinel to a session cookie, which breaks continuity across the interstitial challenge.

Decoding response_html

Both block and redirect carry response_html as base64-encoded UTF-8. Decode and return it as the body, with response.status_code as the HTTP status. That field is the status Centinel wants you to send, not a suggestion: it defaults to 403 for both decisions, and a policy rule or a tenant setting can change it to any of 200, 302, 401, 403, 404, 418, 429, 451, 460, or 503. Hardcoding a status throws away whatever was configured in the dashboard.

A redirect can also arrive with no response_html at all, which happens when interstitial rendering or session encryption failed. Deny those requests with status_code and a short body of your own. Do not pass them through: the validator picked that visitor for a challenge, so treating a rendering failure as an allow hands them the protected content instead. The shipped gateway integration does the same thing, substituting a block page whenever the HTML is missing.

// Node.js
const html = Buffer.from(response.response_html, 'base64').toString('utf8');
// Web platforms (Cloudflare Workers, Deno, browsers)
const html = new TextDecoder().decode(
  Uint8Array.from(atob(response.response_html), c => c.charCodeAt(0))
);

Applying headers

A response can carry a headers object on any decision. Set each entry on your outgoing response. A block can contain Content-Type, X-Content-Type-Options, and X-Frame-Options. An interstitial can contain Content-Type, X-Content-Type-Options, and a Content-Security-Policy whose nonce the challenge script needs. An allow can carry a validator-owned response, such as managed robots.txt, with its own headers. Always guard for a missing headers key.

// Express
for (const [name, value] of Object.entries(response.headers ?? {})) {
  res.setHeader(name, value);
}
// Cloudflare Worker
const headers = new Headers();
for (const [name, value] of Object.entries(response.headers ?? {})) {
  headers.set(name, value);
}
return new Response(body, { status, headers });

Common pitfalls

  • Failing open on a 4xx. A 400, 401, or 409 means the validator rejected your request, not that it is down. Allowing those requests opens a bypass.
  • Calling /validate from the browser. The x-api-key is server-only. Anyone with the key has full tenant access.
  • Hardcoding the HTTP status. Send response.status_code. Hardcoding 200 on redirect answers real blocks with a success status, which scrapers read as a win and caches may store.
  • Assuming headers is always present. It can be absent on any decision. Reading it without a guard can throw.
  • Trusting X-Forwarded-For blindly. Pass the real client IP, but take it from a hop you control. The header is attacker-controlled, and a non-IP value makes the validator answer 400.
  • Ignoring cookies on allow. The cookies array can be non-empty on any decision (session cookies on a happy-path response, challenge cookies on redirect). Apply them all, with their attributes.
  • Dropping the _centinel cookie. Without it, session tracking across interstitial challenges fails, and the visitor is challenged again on every request.
  • Expecting an HTTP 429. Rate limits arrive in-band as a 200 with rate_limited and retry_after in the body. /validate never answers 429.

See also

On this page