Cloudflare error 1010 rejects Python's default User-Agent before the API's auth layer ever sees the request. How to tell it from a real 401, and the four headers that fix it."
Originally published on dhseadev.online.
I lost an hour to a credential that was never wrong.
The task was small: a read-only Model Context Protocol server over a vendor REST API, so an assistant could read job records without me pasting spreadsheet exports into a chat window. The vendor authenticates with the customer's own API key over HTTP Basic. Two lines of urllib. Done before lunch.
Every request came back the same way:
HTTP/1.1 403 Forbidden
error code: 1010
The short version
Cloudflare error 1010 is not an authentication failure. It is a browser-signature ban issued at Cloudflare's edge — "the owner of this website has banned your access based on your browser's signature" — and Python's default User-Agent is enough to trigger it.
The request never reaches the API's own auth layer. That is why every credential you try fails identically. The fix is four request headers, not a new key.
If that solves your afternoon, you can stop reading. The rest is the part I found more useful: how you can tell, from the failures alone, that you are not talking to the thing you think you are talking to.
Why a correct key still returns 403
I did what everyone does. Key as the username with an empty password. Key as a Bearer token. Key in an X-API-Key header. Re-checked the base64 padding. Regenerated the key. Tried again.
Six variants. Every one returned 403 and error code: 1010.
That is the shape of a permissions problem. It is where you start drafting an email to support asking which scope your key is missing, and where the hour goes.
The tell is that the failures were identical
An authentication layer discriminates. That is the entire job description.
A malformed header should not fail the same way as a well-formed header carrying a revoked key, which should not fail the same way as a valid key hitting an endpoint it cannot see. Those are three different conditions and a competent auth layer says three different things about them.
When six materially different requests produce byte-identical responses, nothing is reading those bytes. The answer is coming from something standing in front of the thing you are trying to talk to.
That generalises well past Cloudflare, and it is the part worth keeping: identical failure output across varied input means the input is not being examined. Vary something that should matter. If the error does not move, you are debugging the wrong layer.
In this case the thing in front was Cloudflare's browser-integrity check. Python's standard library announces itself as User-Agent: Python-urllib/3.x, and the User-Agent header is the cheapest fingerprint an edge can filter on. That string alone got every request I sent discarded before the vendor's servers ever saw it.
The fix
Four request headers. No proxy, no scraping framework, no third-party HTTP client.
HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/126.0.0.0 Safari/537.36"
),
"Accept": "application/json, text/plain, */*",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
}
I proved it side by side with a key I made up on purpose:
| Headers | Result |
|---|---|
| Python default |
403 · error code: 1010
|
| Browser set above | 401 HTTP Basic: Access denied. |
A 401 was the win condition. A correct rejection of a deliberately fake credential meant the request had finally reached the application.
The honest caveat: this defeats User-Agent fingerprinting and nothing else. If the edge escalates to TLS or JA3 fingerprinting, urllib stops working again and the answer becomes a browser-impersonating client. I have not tested that, because it has not happened.
The trick I took away: 401 is evidence you can collect for free
The useful consequence showed up later. I had written tools against seven endpoint paths. Five of them I had never successfully called — I had them from documentation, which is a claim, not a measurement.
So I called all seven with the correct headers and no credential at all: account, jobs, companies, court cases, courts, employees, invoices.
Every one returned 401. Not one returned 404.
Those two codes answer different questions. RFC 9110 §15.5.5 defines 404 as the origin server finding no current representation for the target resource — the route does not exist. §15.5.2 defines 401 as a request lacking valid authentication credentials — the route exists and is refusing you.
That distinction confirmed all seven paths, using zero credentials and touching zero customer data. It is the cheapest verification step I know of, and I had never deliberately reached for it before. You can run it against an API before you have a key, before you have written a client, before you have permission to do anything at all.
It does not confirm everything. Proving a door exists is not the same as knowing what is behind it — the field mappings behind five of those tools are still inferred rather than captured, and they are labelled that way in the README until someone runs them against a live key.
One unrelated landmine, since it will cost somebody an afternoon
The mcp package on PyPI is at 2.0.0, and FastMCP is gone from it.
# stale — every tutorial I could find still says this
from mcp.server.fastmcp import FastMCP
# current
from mcp.server.mcpserver import MCPServer
The .tool() decorator and .run(transport="stdio") are unchanged, so the migration is one line. Checked against the SDK rather than against the tutorials, on 4 August 2026 — if you are reading this much later, check it again.
What I actually took from it
Identical failures are a signal, not noise. If varying the input does not vary the output, the input is not being read.
A written environment fact is still a hypothesis. My own handoff notes said neither sandbox could reach that host at all. That was wrong. The network route was always fine and the original blocker had only ever been a missing key — I nearly designed around a constraint that did not exist.
Ask the question that costs nothing first. The unauthenticated probe took ninety seconds and settled something I had planned to settle with a credential I did not yet have.
The server shipped read-only: ten tools, 28 unit tests, no mutating operations. Anything that writes back into a system of record stays behind a confirmation gate, which is its own problem and its own post.
I write these up as I go at dhseadev.online. If this was your kind of thing, the nearest neighbours are the orchestrator layer that routes this work, ServeBoard — the same data seen from the other end, where deciding what counts as one job turned out to be the hard part — and a longer piece on how I work.
Top comments (0)