Developer guideContent security policy

Content security policy and network

Hosts, directives and request shapes, so the loader works on a page with a strict policy and behind a firewall.

Two hosts

HostWho calls itFor
api.otcaptcha.comThe visitor's browserThe loader file, the challenge, the verification, the puzzle picture and the funnel beacon, all under /captcha/v1/.
verify.otcaptcha.comYour server onlyThe server check. Browsers never need to reach it, so it does not appear in your page policy.

Both are served over HTTPS only. No request to either host carries cookies: the loader sends every call with credentials omitted.

Directives

The loader is written to need as little as possible from a page's policy. What it does need, and why:

DirectiveNeedsBecause
script-srcThe origin that serves the loaderThe script tag itself. There is no inline script and no eval.
script-src'wasm-unsafe-eval', recommendedThe solver is WebAssembly embedded in the file. Without this source the browser refuses to compile it and the loader falls back to a JavaScript solver, which is slower. Nothing breaks.
connect-srchttps://api.otcaptcha.com, or your self-hosted API originThe challenge, verification and puzzle calls, and the funnel beacon.
worker-srcThe origin that serves the loaderThe background workers are started from the loader's own URL, never from a blob: URL. When a page has no worker-src, browsers fall back to script-src, which already allows that origin. If workers are refused, the solve runs on the main thread instead.
style-src'unsafe-inline'The box carries its stylesheet in a <style> element it adds to the page. With inline styles refused the box still works but appears unstyled. Colours from the brand look are set as CSS custom properties through the style object, which a policy does not block.
img-srcNo changeThe puzzle picture arrives in the verification channel and is drawn on a canvas. No image URL is loaded.
font-srcNo changeThe box uses the system font stack.

Loaded from us

HTTP header, the relevant sources only
Content-Security-Policy:
  script-src  'self' https://api.otcaptcha.com 'wasm-unsafe-eval';
  connect-src 'self' https://api.otcaptcha.com;
  worker-src  https://api.otcaptcha.com;
  style-src   'self' 'unsafe-inline';

Loader on your own origin

HTTP header, the relevant sources only
Content-Security-Policy:
  script-src  'self' 'wasm-unsafe-eval';
  connect-src 'self' https://api.otcaptcha.com;
  worker-src  'self';
  style-src   'self' 'unsafe-inline';

Only the sources shown are about the loader; merge them into the policy your page already has. A page that uses 'strict-dynamic' with nonces must put a nonce on the loader's script tag like any other script.

Request shapes

The browser sends three kinds of request, and their shapes matter for a web application firewall or a proxy in front of your visitors:

  • GET /captcha/v1/load?captcha_id=…&client_type=…&v=…. No body.
  • POST /captcha/v1/verify and POST /captcha/v1/puzzle with a JSON body sent as text/plain. That content type is what lets the browser send a cross-origin request without a preflight, saving a round trip on a first visit; the service reads the body as JSON regardless of the type. A firewall rule that rejects JSON-looking bodies with a text content type will break the check.
  • POST /captcha/v1/funnel, one sendBeacon with a text/plain body of under 1 KB when a box ends. Nothing waits for it, and a lost beacon only leaves a gap in the funnel.

The service answers a preflight, when a browser does send one, with 204, and allows GET and POST with a Content-Type header, cached for two hours. The Access-Control-Allow-Origin header echoes the page's origin when it is allowed for the service; a browser whose origin is not allowed sees a CORS error in the console and the loader treats the call as unreachable.

Headers on the loader and the API

HTTP
# The loader, as served by us
Content-Type: application/javascript; charset=utf-8
Content-Encoding: br                      # or gzip, or none, by Accept-Encoding
Cache-Control: public, max-age=300, stale-while-revalidate=86400
ETag: "<hash of the file>-br"
Vary: Accept-Encoding

# Every API answer
Content-Type: application/json
Cache-Control: no-store

The loader is precompressed at build time with brotli and gzip, so the compressed bytes are identical on every request and every instance. Each encoding has its own strong ETag, and a returning browser that revalidates gets a 304. The cache life is five minutes, with the browser allowed to use the file once more while it revalidates for up to a day, so a new loader reaches every visitor within minutes of release and a bad one can be withdrawn just as fast.