parsa
Get started

How PerimeterX (HUMAN) works: the sensor, the collector and _px3 explained

How PerimeterX (HUMAN) works: the sensor script, collector posts, the commands that bake _px2 and _px3, what it fingerprints, and how to debug blocks.

Updated 10 min read

In short

  • PerimeterX, now HUMAN Security, loads a sensor script per customer app id and posts what it measures to collector-<appid>.px-cloud.net.
  • The collector answers with a masked command stream; its bake command sets the clearance cookies _px2 and _px3, which last 330 seconds.
  • A session is one page load: each navigation starts a new uuid and sequence, and each post must answer the collector's last reply.
  • The cookies are server verdicts, never computed in the browser, so the only way to them is posts the collector accepts.

PerimeterX is a bot protection vendor that merged with HUMAN Security in 2022. The product now carries the HUMAN name, but on the wire little changed: the scripts still load from px-cloud.net and px-cdn.net, and the cookies still start with _px.

Each customer gets a sensor script under its own app id. The script measures the browser and the visit and posts what it finds to a collector. The collector answers with a stream of commands, one of which sets the clearance cookies, _px2 and _px3. The site’s edge lets requests through while a fresh cookie is present, and blocks or challenges the rest.

This guide covers how to recognise it, its cookies, the sensor-collector flow, what the sensor collects and how it is encoded, what the server checks beyond the payload, and how to debug failures.

How to tell a site uses PerimeterX

Every deployment has an app id: PX followed by eight letters and digits, such as PXa1b2c3d4. The sensor build is per app id, so two customers get different scripts.

The sensor loads in one of two ways.

Third party. The page loads main.min.js from one of several interchangeable CDN hosts. All of them return the same bytes for a given build:

GET /PXa1b2c3d4/main.min.js HTTP/2
Host: client.px-cloud.net

The same path works on client.perimeterx.net, client.px-cdn.net and client.a.pxi.pub.

First party. The site proxies the sensor through its own domain, under the app id with the PX prefix dropped, and the file is called init.js:

GET /a1b2c3d4/init.js HTTP/2
Host: shop.example.com

The first-party file is byte-identical to the third-party one, but the names are not interchangeable: init.js on a CDN host returns 404.

Other signs:

  • Cookies. _pxhd arrives in a Set-Cookie from the site’s own responses. _pxvid, _px2, _px3 and pxcts appear once the sensor has run.
  • Collector traffic. Form-encoded POST requests to collector-<appid>.px-cloud.net/api/v2/collector.
  • A build header. The sensor response carries x-px-hash, which identifies the build. When it changes, the build rotated.
  • Helper hosts. A token fetch from ift.px-cloud.net/ns, a bridge iframe from js.px-cloud.net, and error reports to a clientError path.
  • The block page. It sets globals such as window._pxUuid, window._pxVid and window._pxAppId and loads the challenge from captcha.px-cdn.net/<appId>/captcha.js, or first party from /px/<appId>/captcha/.

The cookies

Cookie Set by What it means Lifetime
_px3 Collector, through the bake command The clearance cookie: a server MAC and encrypted blocks, opaque to the client 330 s
_px2 Collector, through the bake command Companion clearance cookie: base64 JSON with uuid, visitor id, expiry and a server MAC 330 s
_pxvid Collector command The visitor id. Survives across visits One year
_pxhd The site’s edge, Set-Cookie on its HTML and API responses Opaque edge token; the client only echoes it —
pxcts The sensor script, from a value the collector issued The hid value the collector issued —
_pxde Collector, through a second bake command A MAC plus a small JSON record with a timestamp 330 s
pxsid The sensor, in sessionStorage (not a cookie) The session id; survives navigations in the tab Tab session

None of _px2, _px3 or _pxhd is computed in the browser. They are server verdicts, encrypted or MAC’d with keys the client never sees, so there is nothing to forge: the only way to get them is to send posts the collector accepts. And they are short-lived, so a long-running scraper has to keep the sensor conversation going rather than reuse one cookie. The sensor also keeps bookkeeping cookies (_pxac, _px_acp and others); keep everything in one jar.

The flow, step by step

1. The page loads the sensor

The script is plain JavaScript, around 230 KB, obfuscated with string tables, control-flow flattening and randomised names. The sensor builds we have read contain no bytecode VM, and the field identifiers are literals in the script.

2. The sensor posts to the collector

The sensor creates a uuid for this page load and starts a sequence counter at 0. It then posts batches of events:

POST /api/v2/collector HTTP/2
Host: collector-pxa1b2c3d4.px-cloud.net
Content-Type: application/x-www-form-urlencoded

payload=<encoded events>&appId=PXa1b2c3d4&tag=<build tag>&uuid=<page uuid>&ft=<build number>&seq=0&pc=<16 digits>&sid=<session id>&vid=<visitor id>&pxhd=<_pxhd value>

The form carries more fields than shown. uuid and seq identify the page load and the position in it, tag and ft the build, and pc is an integrity digest. vid, sid and pxhd carry the visitor, the tab session and the edge token. cs, cts and hid echo values the collector issued earlier, so they are absent from a page load’s first post.

3. The collector answers with commands

The reply is JSON. Third-party deployments get the commands in ob; some first-party deployments use a do variant with named commands instead.

{ "ob": "<base64 masked command stream>" }

ob is base64, masked with a key derived from the build’s tag, and split into commands, each an opcode with arguments. The commands bake cookies (name, value, lifetime: this is how _px2 and _px3 arrive), set the visitor and session ids, issue a page timestamp and the cs, cts and hid values later posts must echo, set feature flags, plant a hidden decoy element, trigger a reload that hands off to the challenge, or push JavaScript for the sensor to eval and report back.

The opcodes are not stable. Two builds we compared wrote the same commands with entirely different strings, so a hard-coded opcode table decodes the next build’s stream and then silently matches nothing. The build carries its own mapping; read it per build.

4. The next post answers the reply

The sensor applies the commands and posts again with seq=1, echoing what the collector issued. The page timestamp feeds the payload’s noise and is hidden inside sid as invisible Unicode tag characters (on the wire, runs of %F3%A0%84…). The payload also echoes the baked _px3.

So posts are chained: almost everything a post identifies itself with comes from the previous reply, and a client that does not feed each reply into the next post cannot get past the first.

5. Unload: the beacon

When the page unloads, the sensor flushes a final batch with sendBeacon:

POST /api/v2/collector/beacon HTTP/2
Host: collector-pxa1b2c3d4.px-cloud.net
Content-Type: application/x-www-form-urlencoded

payload=<encoded events>&appId=PXa1b2c3d4&uuid=<page uuid>&seq=<n>&…

The beacon is composed ahead of time, so it can carry values the collector has since replaced.

A session is one page load

A PerimeterX session is a page load, not a visit. On every navigation the sensor starts again with a new uuid and seq=0.

Survives a navigation Starts again
sid (sessionStorage) uuid, seq
vid, hid (cookies, read back at start) cs, cts, the page timestamp

A page load’s first post therefore has no cs or cts and no hidden timestamp in sid. Carrying cs into a fresh page states something no browser states.

What the sensor collects

Across 21 captured posts of one build we observed 339 distinct field paths, in these groups.

  • Navigator. Language and languages, platform, user agent, device memory, plugins and their enumeration, the PDF viewer flag, Bluetooth, the UA client hints brand list, navigator.permissions.query and navigator.share as source text, navigator.brave.
  • Automation globals. A sweep for names left behind by automation tools: __webdriver_* variants, domAutomation, _phantom*, __nightmare, spawn, awesomium, the ChromeDriver $cdc_… key, and the shape of window.chrome.
  • WebGL. Vendor and renderer, unmasked vendor and renderer, extensions and parameters, plus MD5 digests of each list.
  • Property sweeps. hasOwnProperty checks over long key lists on window, document, location and navigator, which catch patched or missing properties.
  • Fonts. Several hundred candidate families, each detected by measuring a text span against a fallback baseline; the present ones are joined and hashed into one digest. It is an operating-system fact: two Chrome versions on the same macOS font set give the same digest.
  • Glyph widths. A separate probe measures how unusual Unicode and CJK characters render.
  • Intl and time zone. Intl.DateTimeFormat().resolvedOptions().timeZone, and a check that the time zone agrees across realms.
  • Focus and visibility. Timings of focus, blur and visibility changes.
  • Pointer and keys. Mouse, pointer and key aggregates, and capped streams of pointer samples, scrolls and the press sequence (mousedown, focus, mouseup, click).
  • Screen. Width, height, available size, window geometry, iframe flags.
  • Connection. navigator.connection: effective type, round-trip time, downlink, save-data.
  • Storage. The storage estimate.
  • Self-checks. WebAssembly presence, strict-mode detection, a String.prototype hook probe, and the sensor’s own error messages and stack traces.

Each event has a type and a map of fields. Types and field keys are short base64-looking identifiers that rotate with every build.

How a post is encoded

Conceptually, the payload field is built in four steps:

  1. Serialised. The event list is turned into JSON by the sensor’s own serialiser, which escapes only control characters, quotes and backslashes and leaves non-ASCII text raw.
  2. Masked. Each character is masked with a fixed key, then encoded as UTF-8.
  3. Base64. The masked bytes are base64-encoded.
  4. Noise. Extra characters, derived from the page timestamp, are spliced into the base64 at positions derived from the page’s uuid. The odd characters you see in a captured payload= are this noise, not corruption.

pc is an integrity digest: a keyed hash of the serialised events, keyed from the page uuid and the build’s identifiers, reduced to 16 digits. If payload and pc disagree, the post was tampered with or rebuilt badly.

The codec has held across both builds we read; all 21 captured posts of the first decode and re-encode to the same bytes, digest included.

The press-and-hold captcha

A suspicious visitor gets the press-and-hold challenge: a separate script of about 600 KB, with its own obfuscation and an embedded WebAssembly module. A solve has three parts. The hold, whose required duration the server sets when the challenge loads, recorded with press and release timestamps, geometry, and flags for a honeypot element, programmatic reads of the progress bar and unknown scripts. A SHA-256 proof of work, split across Web Workers. And a WebAssembly output that appears to sign the proof-of-work result.

The record does not go to a separate captcha endpoint. It travels as an event over the same collector channel, with the same codec and command stream. Parsa does not solve the press-and-hold challenge yet.

How the server checks you beyond the payload

A perfect payload sent from the wrong place still fails.

TLS and HTTP/2. The client hello (summarised as JA3 or JA4) and the HTTP/2 fingerprint (SETTINGS values, window update, pseudo-header order) should match the browser your user agent claims. A Chrome user agent over a default Python or Go TLS stack is a mismatch no payload can repair.

Headers. Header order and sec-ch-ua, sec-fetch-* and accept-language should match the claimed browser.

IP and time zone. The payload’s time zone, clock and language should be plausible for the exit IP; a Berlin time zone on a US residential address is easy to score. This is why Parsa requires ip on every call: the machine profile follows the user agent and address together, and the clock follows the address.

Cookie jar continuity. _pxhd is echoed in pxhd, _px3 inside the next payload, and vid and sid tie posts to a visitor and a tab. Drop a cookie, change IP mid-session or switch user agents, and the chain no longer describes one browser.

Consistency across sessions. A value byte-identical across thousands of sessions is a correlation handle. The fingerprint has to vary the way real machines vary.

Debugging checklist

  • The collector answers but no _px3 arrives. Check that each reply feeds the next post.
  • Posts after a navigation fail. You carried cs, cts or the page timestamp into the new page. Start a fresh uuid at seq=0; keep only sid, vid and hid.
  • Your own post’s pc does not match. Look for a latin-1 step. Non-ASCII values, such as a localised date, break the round trip and the digest unless the text stays UTF-8 throughout.
  • A captured payload will not decode. Form parsing turned + into spaces; restore them first.
  • The commands suddenly do nothing. The build rotated and your opcode table no longer matches. Compare x-px-hash.
  • You have _px3 but are still blocked. It expired (330 seconds), the request left from a different IP or TLS fingerprint than the posts, or _pxhd is missing from the jar.
  • The challenge appears on the first request. The edge scored you before the sensor ran: check IP reputation, TLS and header order.
  • Beacon values look stale. Expected; the beacon is a snapshot.
  • Identical code, mixed results. Check time zone and language against each exit IP. Acceptance also varies with the hour and the address, so compare against a control before blaming the payload.

The DataDome and Akamai Bot Manager guides cover the same ground for their sensors.

Solving PerimeterX with Parsa

Parsa writes the collector posts from the sensor script alone, with no browser. You send the script once with userAgent, pageUrl, ip, and optionally acceptLanguage and zone. The answer is the collector URL and the form-encoded body to post there, a build id to send instead of the script next time, and a sealed context. Your client posts the body from its own IP and sends the collector’s reply back as collectorResponse with the context, setting newPage on a navigation. If a site ships a build Parsa has not read, the API answers 409 unknown_build and you send the script once more.

In our benchmarks, writing a post for a known build takes about 1 ms, and reading a new 228 KB build about 25 ms, on an Apple M5 Pro. The 21-post round trip above checks the codec, not live acceptance: we have not published a live success rate for PerimeterX yet, and the press-and-hold challenge is not supported. Only 200 responses are billed; see pricing.

PerimeterX solver APISkip the browser. Send the challenge, post the answer._px3 collector post. Pay per successful solve, never for a failure.

More guides