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
allowfor 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
/validatefrom the browser. Thex-api-keyis server-only. Anyone with the key has full tenant access. - Hardcoding the HTTP status. Send
response.status_code. Hardcoding200onredirectanswers real blocks with a success status, which scrapers read as a win and caches may store. - Assuming
headersis always present. It can be absent on any decision. Reading it without a guard can throw. - Trusting
X-Forwarded-Forblindly. 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
cookiesonallow. Thecookiesarray can be non-empty on any decision (session cookies on a happy-path response, challenge cookies onredirect). Apply them all, with their attributes. - Dropping the
_centinelcookie. 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_limitedandretry_afterin the body./validatenever answers 429.