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.
_pxhdarrives in aSet-Cookiefrom the site’s own responses._pxvid,_px2,_px3andpxctsappear once the sensor has run. - Collector traffic. Form-encoded
POSTrequests tocollector-<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 fromjs.px-cloud.net, and error reports to aclientErrorpath. - The block page. It sets globals such as
window._pxUuid,window._pxVidandwindow._pxAppIdand loads the challenge fromcaptcha.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.queryandnavigator.shareas 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 ofwindow.chrome. - WebGL. Vendor and renderer, unmasked vendor and renderer, extensions and parameters, plus MD5 digests of each list.
- Property sweeps.
hasOwnPropertychecks over long key lists onwindow,document,locationandnavigator, 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.prototypehook 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:
- 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.
- Masked. Each character is masked with a fixed key, then encoded as UTF-8.
- Base64. The masked bytes are base64-encoded.
- 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
_px3arrives. Check that each reply feeds the next post. - Posts after a navigation fail. You carried
cs,ctsor the page timestamp into the new page. Start a fresh uuid atseq=0; keep onlysid,vidandhid. - Your own post’s
pcdoes 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
_px3but are still blocked. It expired (330 seconds), the request left from a different IP or TLS fingerprint than the posts, or_pxhdis 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.