Skip to content

PerimeterX's VID, sensor payload, and the bello challenge internals

· 21 min read
Copyright: MIT
bello wordmark over PX-numbered field markers on a dark background

Load a site behind PerimeterX, open the network tab, and you will see a POST to a path that ends in /api/v2/collector. The body is a short JSON object. One of its keys holds a long base64 string. Decode that string and you get more JSON, except the keys are not words. They are codes: PX315, PX320, PX340, PX257. The string is signed. The script that built it renamed every one of its own functions the moment the page loaded, and it will rename them again on the next refresh. That collector script has a name in the reverse-engineering community. People call it bello, after a token that shows up in the served file. It is the client half of a system that decides whether you get a valid _px3 cookie or a press-and-hold challenge.

This post is about that client half and the identity that ties it together. Not the server-side risk scoring, not the full _px3 and PXHD cookie validation handshake, which has its own post. The question here is narrower. What is the VID, where does it live, and how does it follow a visitor across sessions? What is inside the sensor payload the bello script POSTs, and how is the whole thing obfuscated so that reading the script tells you almost nothing? And when detection trips, what does the press-and-hold challenge actually measure and send back? The exact field layout is not published by PerimeterX, and the script is rebuilt on every load, so where the detail comes from observed traffic and reverse-engineering rather than vendor documentation, this post says so.

The walk goes like this. First the VID and the cookie identity chain it belongs to. Then where the bello sensor comes from and how it reaches the collector. Then the payload itself: the PX-numbered field grammar, what the markers carry, and what stays opaque. After that the obfuscation, the per-load renaming and VM-style protection that make a static read useless. Then the challenge flow, the press-and-hold biometrics, and the signed solution the client returns. Finally what the mobile SDK does differently, and what the whole design says about where PerimeterX puts its trust.

A note on names. PerimeterX merged with HUMAN Security on 27 July 2022, and the combined company now operates under the HUMAN name. The client artifacts still carry the PX prefix everywhere, so this post uses “PerimeterX” for the technology and notes the rebrand where it matters.

The VID and the identity chain

PerimeterX does not rely on a single token. It chains several, and each one does a different job. The visitor identifier, the VID, is the one that persists. It lives in a cookie named _pxvid, and PerimeterX’s own integrations confirm the mapping: the server-side PHP SDK reads _pxvid straight into a context field it calls $this->vid, exposed through a getPxVidCookie() accessor. The VID is a UUID-shaped value assigned to a browser to track it as one visitor across requests and across sessions. Captain Compliance’s writeup on the cookie puts its lifetime at one year, which matches a tracking identifier rather than a per-session token.

That makes the VID the slow-moving anchor in a set of cookies that move at different speeds. The _px3 cookie is the security token, short-lived, refreshed by the collector on each successful evaluation. The _pxvid cookie is the long-lived visitor anchor. Alongside them sit _pxhd, a device-and-history hash the SDK reads into a getPxhdCookie() accessor, and pxcts, a cross-tab session value. On the mobile side the equivalent of the cookie token rides in headers instead: X-PX-Authorization carries the token, and X-PX-Original-Token carries the original where one applies, both read by the same PHP SDK.

The identity chain slow-moving anchor at the bottom, fast-moving token at the top _px3 security token, refreshed each evaluation _pxhd device + history hash pxcts cross-tab session value _pxvid visitor ID (VID), ~1 year, the persistent anchor *The cookie chain PerimeterX maintains, with the VID as the long-lived visitor anchor and _px3 as the fast-rotating token built from each sensor POST.*

The reason the VID matters more than its single-cookie footprint suggests is what it lets the server do. A request without a VID is a first-time visitor, evaluated cold. A request with a VID that has built up a history of clean evaluations is a known-good browser, and the bar for it drops. A VID that has been seen across an implausible spread of IPs, or paired with mismatched fingerprints, is the opposite. The VID is the handle the server-side model grabs to connect today’s request to the same browser’s behavior last week. The collector payload is what keeps feeding that handle new evidence.

It helps to be precise about what the VID is not. It is not a secret, and it is not a proof of anything. Anyone can read their own _pxvid, copy it, clear it, or hand it to a different client. PerimeterX assumes all of that. The value of the VID to the server is purely as a join key. It groups requests that claim to be the same browser so the model can ask whether that claim holds up against everything else: the same TLS fingerprint each time, a plausible geographic path across IPs, behavioral telemetry that stays consistent with one human. A VID copied onto a thousand machines becomes a liability for whoever copied it, because it now ties a thousand inconsistent sessions to one identity, which is exactly the pattern the model is built to surface. The persistence cuts both ways, and that is deliberate. A long-lived anchor rewards a stable, honest browser and penalizes a reused one.

This is also why clearing cookies is a weaker move against PerimeterX than it looks. Dropping the _pxvid does not return you to neutral. It returns you to the cold-start bucket, evaluated without history, which on a well-tuned deployment is a more suspicious place to be than a browser carrying a clean record. The VID is one input the server can recover from other signals anyway: the device-and-history hash in _pxhd, the TLS and HTTP/2 fingerprints, the IP. Losing the cookie loses the convenient handle, not the underlying ability to recognize a returning client.

Where the bello sensor comes from

The collector is a single obfuscated JavaScript file. In a standard third-party deployment the browser fetches it from a PerimeterX-controlled host, and the integration constants in PerimeterX’s own NGINX plugin name the domains involved: perimeterx.net for collectors, px-cloud.net and px-cdn.net for CDN-served assets and captcha. The collector endpoint itself is a path ending in /api/v2/collector, confirmed both by community traffic captures against sites like Fiverr (which POST to <appId>.px-cdn.net/api/v2/collector) and by the mobile collector path in the same family.

Many large sites run PerimeterX in first-party mode, which changes the hostnames but not the mechanics. In that setup the edge is configured to serve the script and proxy the collector under the site’s own domain. PerimeterX’s CDN integration docs spell out the rewrite: an incoming request to https://www.customerdomain.com/<app_id without PX>/xhr/... is mapped to an origin call against collector-<app_id>.perimeterx.net/..., and the script itself is served from a first-party /init.js-style path with /xhr and /captcha siblings. To a browser it all looks same-origin, which is the point: it sidesteps third-party cookie restrictions and makes the traffic harder to block with a simple host rule.

However it is served, the script does two kinds of work once it runs. It probes the browser environment once at startup, and it hooks event listeners that accumulate behavioral data over the life of the page. PerimeterX’s product material for Bot Defender describes the client as an obfuscated module that gathers a large set of device, browser, and behavioral signals, encrypts them, and sends them to the collector to obtain the security token. The community count that gets repeated is on the order of a hundred-plus signals, covering canvas and WebGL rendering, AudioContext, navigator and screen properties, plugin and font enumeration, WebRTC, and the full mouse, scroll, and keystroke stream. The exact set is not published and varies by configuration.

Sensor to token, or sensor to challenge bello script probes + event hooks base64 payload signed, PX-keyed /api/v2/ collector server scores VID + payload + TLS clean: fresh _px3 + updated _pxvid history suspicious: challenge press-and-hold flow *The collector POST is the hinge. The same payload either renews the token and the VID's clean history, or routes the visitor into the press-and-hold challenge.*

The transport is a POST whose JSON body wraps the encoded sensor string. Community reversing of the request lists the fields that ride alongside the payload: an appId (the site key, prefixed PX), a tag that encodes the script version for that site, a uuid request identifier, and counters like rsc (a request count) and seq_rsc. Many of these are stable per site or per session. The interesting one, the part that carries the actual telemetry, is the base64 payload field.

Inside the payload: the PX field grammar

Decode the base64 payload and you get JSON whose keys are PX-prefixed codes rather than English. This grammar is clearest in the mobile SDK, where a researcher’s teardown of the iOS collector documented the outer shape directly. The payload is an object with a type tag t set to a value like PX315, and a data object d whose keys are individual PX codes. From that teardown, the device-and-environment block maps roughly like this: PX320 is the device model, PX321 the device name, PX322 the platform, PX318 the OS version, PX323 a timestamp, PX326 a session UUID, PX327 a short identifier, PX328 an integrity hash (described as a SHA-1 used for tamper detection), PX340 the SDK version, PX341 the application name, and PX348 the bundle identifier.

The browser payload uses the same PX-numbered convention but a different and configuration-dependent set of codes, and that set is not publicly documented as a stable table. What community work has pinned down is specific codes that show up in the challenge path rather than the whole environment map. One writeup on a recent browser script version (cited as 8.7.2) names a key PX11590 for the second payload and an initiated key PX12573 whose computed result must be passed through Math.floor. Those are real observed codes for that version, but they are version-specific. The honest statement is that the browser payload field layout is not public, it differs across the script versions PerimeterX rolls out, and any fixed mapping you see published is a snapshot of one version at one time.

Decoded payload shape (mobile, observed) keys are PX codes, not words; browser uses the same convention, different codes "t": "PX315" type tag for this payload "d": { "PX320": ... device model "PX318": ... OS version "PX326": ... session UUID "PX328": ... integrity hash (SHA-1, tamper check) "PX340": ... SDK version } codes shown are from one observed iOS build; the table is not published and shifts across versions *The decoded mobile payload, from a published teardown. The browser sensor follows the same PX-keyed convention but with a version-specific, undocumented set of codes.*

What unifies both is the integrity layer. The payload is not just collected data, it is collected data plus a value that proves the right script produced it. In the mobile teardown that is PX328, a hash over inputs the server can recompute. In the browser, the equivalent is the signing the product docs allude to when they describe the telemetry as encrypted before it leaves. The practical effect is that a server can reject a payload whose fields look plausible but whose signature does not match what its own copy of the algorithm would generate for those fields. That is the lever that turns “replay yesterday’s payload” from a shortcut into a dead end.

Obfuscation: why a static read tells you nothing

The bello script is built to resist exactly the kind of analysis this post describes. Two techniques do most of the work, and they compound.

The first is heavy obfuscation. A deobfuscated browser script runs to thousands of lines, with one reversing project putting a recent version (8.9.6) at over 9,000 lines once the polyfills and helper functions are expanded. Strings are encoded, control flow is flattened, and the logic that matters is buried among decoys. That alone is standard for anti-bot collectors. The second technique is the one that hurts: the names change on every load. The same project describes it plainly, that with the VM-style protection in place, “every time you refresh the page the function and variable names will change.” So a note you take on this load (function a7Q builds the canvas hash) is worthless on the next, because a7Q no longer exists and the canvas logic now lives somewhere else under a new name.

That combination is why a static read of the file is close to useless and why the work is done dynamically instead, by watching what the script does at runtime rather than what it says. It also explains why published field maps go stale so fast. They are not just describing a secret, they are describing a moving target, and PerimeterX moves it on a schedule.

Per-load renaming same logic, new names on every page load load N function a7Q() { canvas } function k2 () { webgl } var _0xd = sign refresh load N+1 function zP1() { canvas } function m9 () { webgl } var _0x4 = sign *The logic is identical between loads; only the identifiers rotate. A name-based note taken on one load does not survive the next, which is what pushes analysis from static reading to runtime observation.*

There is a reason for this design beyond raw difficulty. PerimeterX wants the cost of keeping a solver working to be continuous, not one-time. A static obfuscator you crack once and you are done. A per-load VM means whoever is solving has to maintain a deobfuscation and tracing pipeline that survives every script update PerimeterX ships, and PerimeterX ships them often. The defender’s cost is a one-time engineering investment in the build pipeline. The attacker’s cost recurs forever. That asymmetry is the actual product.

It is worth being clear about what the renaming does and does not protect, because it is easy to overstate. The renaming does not hide the algorithm in any information-theoretic sense. The same canvas-hashing routine, the same signing step, the same event hooks exist on every load. A patient analyst who instruments the runtime can find them each time. What the renaming defeats is the cheap, durable artifact: a map. You cannot write down “the signing function is called _0xd” and reuse it, because that name is gone next load. So the knowledge that survives across loads has to be expressed in terms that do not depend on names, which is to say in terms of behavior, call patterns, and the structure of what the script touches in the DOM and the global scope. That is harder to capture, slower to build, and far more fragile to maintain. The VM-style protection turns a one-page cheat sheet into a standing engineering commitment.

The polyfills bundled into the script are part of the same strategy in a quieter way. A collector that ships its own implementations of common primitives instead of calling the browser’s built-ins is one that can detect tampering with those built-ins, and one that behaves consistently across the long tail of browsers it has to fingerprint. It also pads the script with thousands of lines of plausible, working code that has nothing to do with the parts an analyst cares about, which raises the cost of finding the parts that matter. None of this is unique to PerimeterX; the same playbook shows up in the Akamai sensor collector and in DataDome’s JS tag. What distinguishes PerimeterX is how aggressively it leans on the per-load rotation as the centerpiece rather than as a finishing touch.

The challenge: press, hold, and prove it

When the server is not satisfied (a missing or stale VID history, a fingerprint mismatch, a payload that does not sign cleanly, a bad IP reputation) it routes the visitor into a challenge instead of issuing a token. The modern PerimeterX challenge is the press-and-hold prompt, marketed under the HUMAN brand as a frictionless alternative to image puzzles. The user presses a button and holds it for a moment. No grid of crosswalks. The interaction looks trivial. The measurement is not.

What the challenge measures during that hold is behavioral biometrics. PerimeterX builds a real-time profile from the micro-signals of the press: the timing and frequency of pointer events during the hold, the small involuntary jitter in a human’s hold versus the dead-still flatness of a scripted one, and the cadence of the animation-frame loop while the button is down. Coverage of the mechanism describes it as watching the frequency of mousedown-style events and requestAnimationFrame ticks to separate organic human noise from a robotic static hold. A genuine hold is never perfectly still. A naive automation of it is. The model is trained to tell those apart.

Pressing the button is the easy part. The hard part is the payload that goes with it. The press-and-hold UI is a front end on the same collector machinery, and completing the challenge means emitting a fresh, valid, signed telemetry payload that carries the recorded motion profile and matches everything else the server already knows: the TLS fingerprint of the connection, the VID’s history, the HTTP/2 frame characteristics. Scrapfly’s writeup frames the bar precisely, that the easy part is pressing the button and the hard part is “the collector script emitting a valid telemetry payload,” with real human motion profiles and matched collector output. All the layers have to line up. A perfect motion recording attached to a payload that fails the signature check, or that arrives over a TLS fingerprint that does not match a real browser, does not pass.

The mobile challenge makes the “prove the right code ran” requirement explicit and computational. The iOS teardown describes the server returning, inside the challenge response, a do array of operators that encode a small program: a sequence like challenge|type|PX259|PX256|op1...op6 with six math operators. The client has to run those operators to compute an integer and return it in a field called PX257. On iOS the operators index a dispatch table inside the native binary, so the answer can only be produced by running PerimeterX’s own code path. It is a proof-of-execution: not “can you do arithmetic” but “did our exact challenge logic run in your client.” The browser challenge is the same idea in JavaScript, where, as noted above, an observed version required flooring a computed value with Math.floor before returning it.

Proof-of-execution challenge (mobile, observed) server returns a do[] array PX259 PX256 op1..op6 client runs the operators via PerimeterX's own code path returns int as PX257 Why it works the integer can only be produced by executing the exact operator sequence the server sent for this session. Reimplementing the math is not enough when the payload must also sign and match the VID, TLS, and HTTP/2 layers. *The server hands the client a tiny operator program and demands the result back in PX257. It is a proof that the genuine challenge logic ran, not just that arithmetic is possible. This diagram describes the mechanism; it is not a recipe.*

This is the part of the system that resists a clean-room reimplementation hardest. The behavioral measurement can in principle be fed synthetic-but-realistic motion. The signature can in principle be recomputed if you have ported the algorithm. The proof-of-execution wants the actual challenge code to have run, and on mobile that code is compiled ARM with stripped symbols, while in the browser it is the per-load VM. Both make “just port the function” a continuously decaying investment.

The mobile SDK: same idea, harder shell

PerimeterX’s mobile SDK solves the same problem with different machinery, and the difference is instructive. In the browser, the logic is JavaScript, hostile but ultimately readable at runtime. In the iOS SDK, the equivalent logic is compiled into the app binary. The teardown that documented the PX field codes describes roughly two megabytes of ARM64 code with stripped symbols, where the challenge computation happens in native instructions rather than in any interpretable script. The dynamic dispatch through a lookup table at a fixed binary address is the native analogue of the browser VM: server-sent values select which native function runs.

The payload story is the same one in a tougher wrapper. The mobile collector hits paths like /api/v1/collector/mobile and /api/v2/collector, the token rides in X-PX-Authorization rather than in a cookie, and the PX-keyed payload carries the device fingerprint with PX328 as its integrity hash. The merger that produced HUMAN folded PerimeterX’s mobile protection into the broader platform, and the collective signal work that feeds detection draws on both web and app telemetry. The client artifacts kept their PX naming through all of it.

The takeaway from the mobile side is that PerimeterX treats the client as something it can make expensive to forge but never trusts on its own. A native binary is harder to read than a script, but it still runs on a device the operator controls. So the design does not bet everything on the shell being unbreakable. It bets on the combination: the shell raises the cost, the signature ties the payload to genuine collection, the VID ties this session to a history, and the server-side model judges the whole bundle against everything else it can measure on the wire.

What the client design actually tells you

Strip away the field codes and the obfuscation tricks and the architecture is simple to state. PerimeterX puts a persistent identity in the browser (the VID), feeds it a continuous stream of signed telemetry from a collector that is rebuilt on every load (bello), and when the stream is not convincing, demands a proof-of-execution wrapped in a behavioral challenge (press-and-hold). No single piece is meant to be unbreakable. The VID can be cleared. The payload can be studied at runtime. The challenge math can be ported. What is meant to be unbreakable is keeping all of them aligned at once, indefinitely, against a script and a binary that change underneath you.

That is why the most durable detail in this whole post is not any PX code, since those rotate, but the per-load renaming and the proof-of-execution. Those are not facts about a payload, they are facts about cost. The PX field numbers in a published teardown are a snapshot, true for the version someone happened to capture and stale by the next deploy. The honest reference value here is structural: a VID that persists for about a year as the server’s handle on a browser, a signed PX-keyed sensor payload that has to match the TLS and HTTP/2 layers around it, and a challenge that wants the genuine code to have run, not merely the right answer. Quote the structure. Treat any fixed field map as a date-stamped observation, because PerimeterX wrote it to expire.


Sources & further reading

Further reading