Headless detection

June 18, 2026 ยท View on GitHub

Last modified: 2026-06-18

Header-only heuristics that flag headless and stealth-browser clients even when their TLS / JA4 fingerprint matches a real browser. Pairs with the rule-based agent detection (request.agent.score) and the JA4 scorer.

What it catches

Vanilla automation tooling (Puppeteer, Playwright, Selenium with default config) ships an obvious automation marker in the User-Agent. The TLS layer catches the rest of the unstealthy cases. The remaining gap is stealth wrappers (puppeteer-stealth, undetected-chromedriver, Playwright with the stealth plugin) that patch the JS-side navigator.webdriver and rotate the JA4 vector but cannot rewrite the request shape itself. Their requests carry a Chrome User-Agent but lack the Sec-Ch-Ua and Sec-Fetch-* families that every real Chrome navigation sends.

The deterministic indicators below score these requests without running a model, without running JavaScript on the client, and without holding any session state.

Indicators

IndicatorFires whenWeight
automation_marker_in_user_agentUA contains HeadlessChrome, PhantomJS, Puppeteer, Playwright, Selenium, WebDriver, or SlimerJS60
claims_chrome_without_client_hintsUA carries the Chrome vendor token but no Sec-Ch-Ua / Sec-Ch-Ua-Mobile / Sec-Ch-Ua-Platform header is present25
claims_chrome_without_sec_fetchUA carries the Chrome vendor token but no Sec-Fetch-* fetch-metadata header is present25
accept_language_missingthe request omits Accept-Language entirely15
accept_encoding_anomalousthe Accept-Encoding value does not match a canonical browser order (gzip, deflate, br or gzip, deflate, br, zstd)10

Weights add up; the score saturates at 100. Score bands:

ScoreInterpretation
0-19indistinguishable from a real browser
20-49one or two stealth hints; low confidence
50-79several hints; high-confidence headless
80-100obvious automation; vanilla headless saturates

Real Firefox and Safari requests never trip the Chrome-only indicators because the heuristic gates the Sec-Ch-Ua and Sec-Fetch checks on a Chrome vendor token in the UA. Firefox and Safari requests without the Sec-Ch-Ua family are expected; the heuristic does not flag them.

Surface

The indicators are computed automatically when proxy.extensions.agent_detect.enabled is set; the same site that builds Signals for the rule pack also runs the header-only headless extractor. Two CEL bindings are exposed under the existing request.agent.* namespace:

  • request.agent.headless_score - integer 0-100.
  • request.agent.headless_indicators - list of indicator names that fired.

Example: block obvious headless above 50

proxy:
  extensions:
    agent_detect:
      enabled: true
      rule_pack_path: /etc/sbproxy/agents.yml
      onnx_model_path: /etc/sbproxy/ja4-catboost.onnx

origins:
  "secure.example.com":
    action:
      type: proxy
      url: http://backend:3000
    policies:
      - type: expression
        expression: 'request.agent.headless_score < 50'
        deny_status: 403
        deny_message: "automation suspected"

Pair with request.agent.score and the JA4 verdict for a layered defence: a benign request scoring low on every dimension passes; a stealth headless that defeats one layer still trips the others.

onnx_model_path loads an in-process CatBoost ONNX scorer at startup. When both rule_pack_path and onnx_model_path are set, exact rule-pack identity matches win and the ONNX scorer runs on rule misses.

Scope and limitations

This module is the deterministic, request-side half of the headless-detection design. Two further layers compose on top in follow-ups:

  • JS-execution challenge: serve a script that posts a token back on first navigation; absence of the token on subsequent requests is a stronger signal than any header heuristic.
  • Session-window consistency: header-order hash drift across the same session is a strong stealth indicator; needs the session-tracking surface to land.

The proprietary ML score that Akamai Content Protector pairs with these heuristics stays an integration boundary; this module is the open half.

See also

  • scripting.md - the full CEL / Lua / JavaScript / WASM expression surface.
  • crates/sbproxy-agent-detect/src/headless_indicators.rs - source.
  • The JA4 CatBoost scorer that this pairs with.