Quick answer
Docker Hub's public /v2 REST API returns HTTP 403 to a Chrome TLS fingerprint and HTTP 200 to a Firefox one — same URL, same IP, same second. No auth, no rate limit, no bot score. Just the fingerprint.
If your Docker Hub client is failing with a Cloudflare "Just a moment..." page, you probably don't have an IP problem. You have a chrome problem.
The assumption that was wrong 🐳
When we spec'd the Docker Hub Images & Tags Scraper, the Open Questions section said, in writing:
Docker Hub's public
/v2API needs no anti-bot evasion.
Reasonable. It's a public, keyless, documented REST API. You can hit it from a browser with no session. Nothing about it looks defended.
Our HTTP layer defaults to curl_cffi with Chrome impersonation, because that's the repo-wide default for everything we build. So the first real run went out as chrome131 and came back with this:
HTTP 403
<!DOCTYPE html><html lang="en-US"><head><title>Just a moment...</title>
server: cloudflare
The probe that settled it
The useful move here is not to reach for a proxy. It's to change exactly one variable and re-run. Same URL, same machine, same minute:
from curl_cffi import requests
url = "https://hub.docker.com/v2/repositories/library/nginx/tags?page_size=1"
for profile in ("chrome131", "chrome124", "firefox135", "firefox133", "safari184"):
r = requests.get(url, impersonate=profile, timeout=25)
print(profile, r.status_code)
chrome131 -> HTTP 403 Just a moment...
chrome124 -> HTTP 403 Just a moment...
firefox135 -> HTTP 200 {"count":1311,"next":"https://hub.docker.com/v2/...
firefox133 -> HTTP 200 {"count":1311,...
safari184 -> HTTP 200 {"count":1311,...
Every /v2 endpoint behaves the same way — tags, repository detail, and search. The IP never changed. The headers never changed. Only the TLS and HTTP/2 fingerprint did.
Why would Chrome be the blocked one? 🤔
It sounds backwards — Chrome is the most common browser on earth, so surely it's the safest thing to look like?
That's exactly why it isn't. Cloudflare's fingerprint checks don't ask "is this a plausible browser." They ask "does this TLS fingerprint match the rest of this request?" A real Chrome browser hitting hub.docker.com/v2/... arrives with a Chrome fingerprint and Chrome's full header set, ordering, sec-ch-ua hints and navigation context. An impersonation library reproduces the fingerprint precisely and the surrounding context imperfectly.
The more heavily a profile is impersonated in the wild, the more precisely the mismatch gets tuned. Chrome is the most-impersonated profile in existence, which makes it the most expensive one to get slightly wrong. Firefox and Safari carry less scrutiny for the same request — not because they're stealthier, but because almost nobody bothers to fake them.
So the counterintuitive rule: the most popular disguise is the most closely inspected one.
What we shipped
Not a hardcoded firefox135. A single profile is a single point of failure — the day Cloudflare re-tunes, every run dies at once.
# chrome131/chrome124 are confirmed 403'd (Cloudflare JS challenge) against
# hub.docker.com/v2/* as of 2026-09-05 — never add a chrome* profile back
# without re-probing live first.
BROWSER_PROFILES = ("firefox135", "firefox133", "safari184", "safari180")
Three things worth stealing from that snippet:
- Rotate across a known-good tuple, don't pin one profile.
- Date the finding in the comment. Anti-bot posture is a moving target; a comment that says "as of" tells the next reader whether to trust it.
- Write down what's forbidden and why. The next person to "simplify" this back to the repo default needs to hit the reason before they hit the 403.
The part that generalises 🧭
This is the third time we've watched a Chrome fingerprint be the thing getting us blocked on a target we'd already written off as an IP problem. The ECB's data portal did it. So did a certificate-transparency endpoint. Now Docker Hub.
But the durable lesson isn't about Chrome. It's about the sentence in our own spec:
Docker Hub's public
/v2API needs no anti-bot evasion.
That was true when someone wrote it, and it was load-bearing, and nothing in our pipeline ever re-checked it. Tests don't check it — every mocked test passed. Code review doesn't check it, because it reads as a statement of fact about the world rather than a claim about our code.
Our written assertions decay, and nothing re-probes them. The fix is cheap and boring: when a spec line asserts something about a live third-party service, spend the thirty seconds to curl it before you build on top of it. Every time we've skipped that, the bill came due at cloud-QA — or worse, at a customer's first run.
What the Actor gives you
One row per repository + tag pair, straight from the public /v2 API:
- digest, compressed size, and
last_pushed/last_pulledfreshness - deduplicated architecture list (
amd64,arm64,arm) per tag - full tag pagination — we follow the
nextcursor to your configured cap, not just page one - repository or free-text search input, plus a resolvable Docker Hub URL on every row
Rate-limit headers are read on every response, and the client pauses until reset when the remaining budget runs low, so a wide tag sweep degrades into slowness rather than a wall of 429s.
The honest limitations 🚧
- Public repositories only — no auth means no private namespaces.
- Docker Hub's search ranking is theirs, not ours; a search term resolves to their top matches.
- Per-architecture digests come from the tag's image list, so a tag with an unusual manifest layout can report fewer architectures than
docker manifest inspectwould.
Pricing
$0.20 per run plus $0.002 per row — about $2.20 per 1,000 results. A run that finds nothing costs the start fee and nothing else.
→ Docker Hub Images & Tags Scraper on Apify
Built by Devil Scrapes. We handle the fingerprints, the pagination cursors, the rate-limit headers and the assumptions that quietly went stale, so you get a flat table instead of a weekend.
Top comments (0)