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
| Host | Who calls it | For |
|---|---|---|
api.otcaptcha.com | The visitor's browser | The loader file, the challenge, the verification, the puzzle picture and the funnel beacon, all under /captcha/v1/. |
verify.otcaptcha.com | Your server only | The 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:
| Directive | Needs | Because |
|---|---|---|
script-src | The origin that serves the loader | The script tag itself. There is no inline script and no eval. |
script-src | 'wasm-unsafe-eval', recommended | The 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-src | https://api.otcaptcha.com, or your self-hosted API origin | The challenge, verification and puzzle calls, and the funnel beacon. |
worker-src | The origin that serves the loader | The 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-src | No change | The puzzle picture arrives in the verification channel and is drawn on a canvas. No image URL is loaded. |
font-src | No change | The box uses the system font stack. |
Loaded from us
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
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/verifyandPOST /captcha/v1/puzzlewith a JSON body sent astext/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, onesendBeaconwith atext/plainbody 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
# 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-storeThe 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.