FunCaptcha (Arkose Labs): why the token is bound to your session — and the three ways people break it
FunCaptcha used to be the easy one. You found the public key in the page, you called the endpoint, you got a token, you posted it back. That stopped working around the time Arkose Labs started calling the product "Arkose Bot Manager" and putting an enforcement endpoint in front of every challenge.
The token is still there. What changed is that the token is no longer a standalone answer to a puzzle — it is the tail end of a session that started before you ever saw a challenge, and every field in it is cross-checked against what the browser actually did.
This post is about that session, the three ways a solve breaks, and how to read the failure instead of retrying it.
What a FunCaptcha solve actually consists of
Three things, in order:
- A public key (
arkose_labs_..., or the older UUID form) that identifies the site's Arkose application. - A blob — the session state: encrypted risk signals collected from the page, the browser, the network, plus the enforcement decision (pass, challenge, or block). It arrives in the
fc-tokenblob or fromhttps://client-api.arkoselabs.com/fc/gt2/public_key/<key>. - A token — what you get back after the challenge is passed. This is what your target site actually verifies.
The common mistake is to treat step 3 as the whole task. On a plain image captcha, it is. On FunCaptcha, the token is issued against the blob from step 2, and the target site's backend calls Arkose's verify endpoint with both.
The three failure modes, and what each one means
1. ERROR: Invalid token — the token was solved against a stale blob
The blob has a lifetime measured in minutes and it is bound to a session fingerprint. If you fetch it, wait, then solve, the token comes back valid-looking but the verify call rejects it.
Fix: treat blob fetch and solve as one operation. If the solve queue is slow, refetch before submitting.
2. ERROR: Session mismatch — the browser that got the blob is not the browser that submits
The blob encodes a device/browser signal set. Submitting the token from a different IP, a different user agent, or a different TLS fingerprint than the one that requested the blob fails the check even when the token itself is perfect.
Fix: one identity per session, from blob to submit. This is the same rule as DataDome (I wrote about why rotating proxies makes DataDome worse), and it applies here ten times harder because Arkose is a risk engine — a mismatched device is not "low confidence", it is a signal.
3. ERROR: Challenge not passed — the puzzle result was correct but the interaction was not
Arkose scores how the puzzle was solved: timing, pointer paths, keystroke cadence, whether the image tiles were even looked at. Two answers can be equally correct and score differently.
Fix: this is the one you cannot paper over in code. Solve with a service that handles the interaction model, or reduce the challenge rate upstream by looking less like a bot before the challenge appears.
Reading the payload before you retry
Before blaming the solver, dump what you actually sent:
import json payload = json.loads(blob_raw) # what the client-api returned print(payload.get("challenge", {}).get("type")) print(payload.get("session", {}).get("id")) print(len(payload.get("data", {}).get("blob", ""))) | Symptom | Likely cause | What to change |
|---|---|---|
| Token rejected instantly | Blob expired between fetch and submit | Fetch, solve, submit in one pass |
| Token rejected after the site's own verify | IP / UA / TLS changed mid-session | Pin the identity per session |
challenge not passed on a correct answer | Interaction score too low | Stop scripting the puzzle; change the upstream footprint |
| No challenge at all, straight to block | Risk score already too high | The problem is before FunCaptcha, not in it |
The last row is the one people miss. If Arkose returns a hard block instead of a challenge, no solver will help, because there is no challenge to solve. That is a proxy reputation and request-shape problem.
A minimal working flow
import requests API = "https://api.example-solver.com/v1/tasks" # your solver endpoint HEADERS = {"Authorization": f"Bearer {TOKEN}"} task = requests.post(API, headers=HEADERS, json={ "type": "funcaptcha", "websiteURL": target_url, "websitePublicKey": public_key, "data": {"blob": blob_raw}, }).json() # poll import time for _ in range(60): r = requests.get(f"{API}/{task['taskId']}", headers=HEADERS).json() if r["status"] in ("ready", "failed"): break time.sleep(2) print(r["status"], r.get("solution", {}).get("token")) Two notes that save hours:
- Pass the blob as
data.blob, not as a query param on the public key. The verify endpoint compares them. - Keep the polling interval at 2–5s. Hammering the status endpoint gets you rate-limited by the solver and by Arkose, and the second one leaves a mark on the session.
Why this matters more in 2026
Arkose's enforcement API now runs on a large share of the sites that used to be plain reCAPTCHA — ticketing, gaming logins, account recovery. The challenge itself is a small part of the defence; the session and the risk score around it are the rest. Code that treats "get token" as the interface will keep working on easy sessions and keep failing on the ones you actually care about.
If you want a reference implementation rather than writing your own, I maintain SolveHub (I'm the author) — it takes type: "funcaptcha" with the same blob field shape shown above, and bills per completed task rather than per attempt, which matters when you are debugging sessions rather than puzzles.
Next in this series: PerimeterX / HUMAN — the _px3 cookie, and why the payload you send is checked against the page you claim to be on.