PerimeterX _px3: the two expiries that kill your cookie (and the s2s path that has none)
PerimeterX (today HUMAN Security) does not ask your scraper a question at request time. It hands out a cookie, _px3, that says "this visitor already passed", and then it decides later - server-side, per request - whether
PerimeterX (today HUMAN Security) does not ask your scraper a question at request time. It hands out a cookie, _px3, that says "this visitor already passed", and then it decides later - server-side, per request - whether to still believe that sentence. That is why a _px3 solved ten minutes ago can be dead, while one issued an hour later keeps working.
This post is about the two separate expiries inside a PerimeterX deployment: the lifetime baked into the cookie, and the risk score that can override it. Then the server-to-server variant, where there is no cookie to reuse at all.
Scope: everything below is for QA automation on properties you are authorised to test.
What is actually inside _px3
A _px3 value is a colon-separated blob, not opaque randomness. Its payload carries the application ID the cookie was minted for (the PX... value in the page bootstrap), a visitor identifier, an issue time and an expiry chosen by the server, and an HMAC computed over those fields together with the IP and User-Agent of the original request.
Two consequences follow immediately:
- The cookie is bound to a network and a device fingerprint. Move it to another IP or swap the UA and the signature stops verifying.
- The expiry sits inside the signed payload, so you can read when it dies - but you cannot extend it. Only the collector can mint a new one.
Clock one: the expiry in the payload
Every _px3 carries the moment it stops being accepted. For high-risk sessions that window is short - minutes, sometimes barely longer than the time it takes to load one page. For sessions that look clean it can stretch to hours. Neither number is a constant of the product; both come from the tenant configuration and from how the visitor behaved.
Practically, this means any code that stores _px3 with a hardcoded "valid for one hour" assumption is wrong in both directions: it wastes good cookies and, far worse, it sends dead ones.
Clock two: risk decay
The second expiry is not a number at all. PerimeterX scores the session continuously. A session that passed an hour ago but now does something out of character - new User-Agent mid-session, navigation faster than a human can click, a datacenter IP where a residential one was used before - gets re-scored, and the next response comes back with a different verdict: a fresh _px3 with a much shorter TTL, or a challenge.
So the TTL is an upper bound, not a promise. Treat the payload expiry as "at most this long", and treat every response as the authoritative answer.
Why 403 is a weak signal
The instinct is to watch for 403 and re-solve on it. That misses most failures:
- PerimeterX frequently answers 200 with a challenge document - the block is in the body, not the status code.
- A rejected cookie is often replaced silently: you get 200, the content you wanted, and a
Set-Cookiethat swapped_px3for a short-lived one. Your next request dies instead of this one. -
_pxvidand_pxhdcan outlive_px3. Seeing a valid visitor cookie tells you nothing about whether you may pass.
A robust check looks at three things per response: does the body contain the sensor script or a captcha iframe; did _px3 change in a way that shortens its expiry; and does the same request succeed again from the same IP and UA. Solving on 403 alone is solving one out of three failure modes.
The s2s path: no cookie to reuse
Server-to-server deployments move the decision out of the browser. Your backend asks the collector for a verdict, gets a signed token, and attaches it to the request. Three properties matter if you automate against it:
- The token is per request. There is no cookie jar to cache, and replaying a used token fails.
- It is bound to the request body and the caller IP, so a token minted for
/api/v2/pricesdoes not authorise/api/v1/checkout. - There is nothing to "solve" in the browser sense: no image, no sensor, no iframe. If a solver product hands you a cookie for an s2s-protected endpoint, the endpoint is not actually running s2s.
Client-side _px3
|
Server-to-server | |
|---|---|---|
| Where the decision happens | Browser sensor posts to the collector | Your backend asks the collector |
| What you carry |
_px3 plus _pxvid / _pxhd
|
a per-request signed token |
| Lifetime | minutes to hours, tenant-dependent | one request |
| Bound to | app ID + IP + User-Agent | app ID + IP + request body |
| Poolable across workers | effectively no | no |
| Typical failure | silent re-challenge, short TTL | failed authorisation on every call |
Three failure patterns worth fixing first
Cookie pools shared across proxies. The classic: solve a hundred cookies behind a hundred residential IPs, then round-robin them. Each cookie now runs on the wrong IP, the HMAC no longer matches, and the site answers with a challenge that looks like a rate limit.
Hardcoded TTLs. Read the expiry out of the payload, subtract a safety margin, and store that per cookie. Anything else is a guess that silently rots.
Ignoring the returned User-Agent. A solver that returns solution.useragent is telling you which fingerprint the cookie was minted under. Replaying on your own UA is the same mistake as replaying on the wrong IP, and it is invisible in logs because both requests look fine in isolation.
Here is the correct shape of the loop, using the REST endpoint:
import os, time, requests
base = os.environ["SOLVEHUB_URL"].rstrip("/")
headers = {
"Authorization": f"Bearer {os.environ['SOLVEHUB_API_KEY']}",
"Idempotency-Key": "px-refresh-001",
}
payload = {
"type": "perimeterx",
"task": {
"websiteURL": "https://example.com/protected",
"websiteKey": "PX_EXAMPLE_APP_ID", # pxAppId, not the visitor UUID
"proxyType": "http",
"proxyAddress": "203.0.113.10",
"proxyPort": "8080",
},
}
task_id = requests.post(f"{base}/v1/tasks", headers=headers, json=payload, timeout=20).json()["taskId"]
while True:
task = requests.get(f"{base}/v1/tasks/{task_id}", headers=headers, timeout=20).json()
if task["status"] == "completed":
cookies = task["solution"]["cookies"] # _px3, _pxvid, ...
ua = task["solution"]["useragent"] # replay with THIS UA
break
if task["status"] in ("failed", "cancelled"):
raise RuntimeError(task.get("errorCode"))
time.sleep(2)
# The cookie is only valid on the proxy and UA that solved it.
session = requests.Session()
session.cookies.update(cookies)
session.headers["User-Agent"] = ua
session.proxies = {"http": "http://203.0.113.10:8080", "https": "http://203.0.113.10:8080"}
One trap in that response: solution.value can come back as an empty string on cookie-style challenges. Judge success on solution.cookies, not on value being non-empty - otherwise a perfectly good solve looks like a failure.
The rule of thumb
A _px3 is not a credential you own, it is a claim about a session that the server re-evaluates on every request. Cache it with its own expiry, pin it to the IP and UA that minted it, and let the response - not the status code - tell you when it is gone.
I maintain SolveHub, a CAPTCHA and anti-bot solving API for authorised testing; the PerimeterX endpoint and its request fields are documented at https://www.tiam.codes/docs/perimeterx. The shortcuts above are the same ones I had to learn the hard way.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.