# Validation

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

Source: https://docs.centinelanalytica.com/install/validation

## Overview

If a [platform integration](https://docs.centinelanalytica.com/platforms.md) 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](https://docs.centinelanalytica.com/admin/policy.md), 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

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

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

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

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

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

### Failing safely

> **Warning:** 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.

```ts
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 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. The validator picked that visitor for a challenge, so an allow hands them the protected
content. The shipped gateway integration substitutes a block page whenever the HTML is missing.

```js
// Node.js
const html = Buffer.from(response.response_html, 'base64').toString('utf8');
```

```js
// 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.

```js
// Express
for (const [name, value] of Object.entries(response.headers ?? {})) {
  res.setHeader(name, value);
}
```

```js
// 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 caches may then 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

* [Validation API reference](https://docs.centinelanalytica.com/api/validation.md): POST /validate endpoint spec: schemas, decisions, errors.
* [Platform integrations](https://docs.centinelanalytica.com/platforms.md): Drop-in modules for Cloudflare, Fastly, Nginx, Next.js, and more.
* [Policy Rules](https://docs.centinelanalytica.com/admin/policy.md): Allow, block, rate-limit, or watch requests, and set the crawler allowlist priority.
