We use cookies to enhance user experience, personalize content, and analyze traffic. Cookie Policy

← Back to all articles

AI Agent CAPTCHA: How Websites Detect and Challenge AI Agents

An AI agent CAPTCHA is the output of a risk score. How websites detect AI agents through IP reputation, TLS fingerprints, browser signals, and behavior.

by Unknown Proxies

13 min read

November 2, 2026

AI Agent CAPTCHA: How Websites Detect and Challenge AI Agents

An AI agent CAPTCHA is rarely the thing that detected your agent. By the time a checkbox or puzzle appears, the site has already scored the visit from evidence it collected in layers: who owns the IP, how the TLS handshake looks, which headers arrived, what the page's JavaScript found in the browser, and how the agent moved and typed. The CAPTCHA is just what the site does with a middling score.

That's how websites detect AI agents in practice. Very little of it is specific to AI. An LLM-driven browser gets caught by the same checks as any other automation, plus a few new ones: self-declared agent user agents, signed requests, and hidden-text traps that only a language model would follow.

We measured each layer on 2026-11-02 from a Hetzner server in Nuremberg, using the clients agents actually run on: Python HTTP libraries, Node and Bun fetch, curl, and Playwright driving headless Chrome. The results below show which layer catches which setup, and why patching a single tell rarely changes the outcome.

Five detection layers (network, TLS and HTTP/2, headers and identity, page JavaScript, behavior) feeding one risk score that leads to allow, CAPTCHA or challenge, or block

How websites detect AI agents: the five layers

Each layer is visible at a different moment. The early ones are cheap for the site and need nothing from the browser. The later ones need JavaScript on the page.

Layer What the site reads When What we found
Network IP owner, ASN type, reputation feeds On connect Our server was flagged as hosting by one database and "VPN, risk 66" by another
TLS and HTTP/2 JA4 hash, HTTP/2 SETTINGS frame Handshake Python clients looked alike; Playwright's default headless shell sent one TLS extension fewer than Chrome
Headers and identity User-Agent, client hints, Signature-Agent First request Both Chrome builds put HeadlessChrome in the User-Agent
Page JavaScript navigator.webdriver, WebGL, plugins, timezone After load Software rendering and UTC on every setup, even with the webdriver flag off
Behavior Pointer path, key timing, action rhythm During the session A Playwright click was one pointer move and a press under 1.5 ms

Commercial bot managers combine these into a single number. Cloudflare's bot score runs from 1 to 99, where 1 means automated, 2-29 likely automated, and 30-99 likely human. Its engines include heuristics, a machine learning model, and JavaScript Detections, an invisible script that looks for headless browsers and automation tools. Other vendors differ in detail, but the shape is the same: many weak signals, one decision.

Network layer: IP reputation decides first

Before your agent sends a byte of HTTP, the site knows which network the connection came from. Most agents run on cloud servers, and IP intelligence databases label cloud ranges as hosting.

We queried two public databases for our server's IP. The server runs no VPN or proxy:

Database Verdict
ip-api.com "hosting": true, "proxy": false, ISP Hetzner Online GmbH
proxycheck.io "type": "VPN", "proxy": "yes", risk 66

The two databases disagree about what the server is, and the stricter one calls a bare server a VPN. Sites buy different feeds, so the same agent can pass one site and get an anonymous proxy detected page on the next. Either way, "hosting" is a cheap and strong signal, because very little human browsing comes from a data center.

Declared AI agents are treated differently at this layer. OpenAI publishes the IP ranges behind ChatGPT-User and its crawlers on its bots page, so a site can check that a request claiming to be ChatGPT really came from OpenAI. Our ClaudeBot and GPTBot guide shows that check against server logs.

To see what your agent's exit looks like, ask the same kind of database from the machine or proxy the agent uses:

curl -s "http://ip-api.com/json/?fields=isp,as,mobile,proxy,hosting"

From our server it printed {"isp":"Hetzner Online GmbH","as":"AS24940 Hetzner Online GmbH","mobile":false,"proxy":false,"hosting":true}. Add -x with your proxy URL to check a proxy exit instead.

TLS and HTTP/2 fingerprints: caught before the first header

The TLS Client Hello lists cipher suites, extensions, and ALPN protocols in an order set by the client's TLS library. JA4 condenses that into a short fingerprint, and Cloudflare exposes JA3 and JA4 to Bot Management customers. HTTP/2 adds a second fingerprint from the SETTINGS frame, window update, and pseudo-header order, first described in Akamai's research on passive HTTP/2 fingerprinting.

We sent each client to the BrowserLeaks TLS echo with default settings:

Client JA4 HTTP/2
Python urllib (3.12) t13d1713h1_ab0a1bf427ad_89ab6efea773 No
requests 2.34.2 t13d1712h1_ab0a1bf427ad_882d495ac381 No
httpx 0.28.1 t13d1712h1_ab0a1bf427ad_8e6e362c5eac No
aiohttp 3.14.5 t13d1712h1_ab0a1bf427ad_8e6e362c5eac No
Node 24 fetch t13d5212h1_b262b3658495_8e6e362c5eac No
Bun 1.3.14 fetch t13d1714h1_5b57614c22b0_43ade6aba3df No
curl 8.18 (OpenSSL) t13d3013h2_1d37bd780c83_8537cf56674e Yes, m,s,a,p order
curl_cffi 0.17, impersonate="chrome" t13d1517h2_8daaf6152771_cb7bf5808d99 Yes, Chrome's
Playwright, headless shell 148 t13d1516h2_8daaf6152771_d8a2da3f94cd Yes, Chrome's
Playwright, headless shell 156.0.8078.4 t13d1516h2_8daaf6152771_806a8c22fdea Yes, Chrome's
Playwright, Chrome for Testing 156 t13d1517h2_8daaf6152771_cb7bf5808d99 Yes, Chrome's

Four things stand out:

The mismatch that gets HTTP clients blocked is a browser User-Agent on top of a library handshake. Our HTTP 403 guide covers that case and how to confirm it, and the web scraping headers guide explains when an honest bot User-Agent is the better choice.

Headers and declared identity: AI user agents

Headers arrive with the first request, and they are where an agent either identifies itself or contradicts itself.

Headless Chrome still says so. Both Chrome builds put HeadlessChrome/<version> in the User-Agent header and in navigator.userAgent. That includes Chrome 156's new headless mode, which, per Chrome's headless docs, shares its code with headed Chrome. The older headless shell, which Chrome has shipped as a separate chrome-headless-shell binary since Chrome 132, also lists HeadlessChrome in its sec-ch-ua client hint.

The headless shell sends no Accept-Language. We logged the raw requests on a local server. Playwright's default browser (the shell) sent no Accept-Language header at all, while Chrome 156 sent en-US,en;q=0.9. Desktop Chrome sends one on every navigation. Under Playwright, Chrome 156 also sent Accept-Language last instead of before Upgrade-Insecure-Requests, where the same browser put it when no driver was attached. That was HTTP/1.1 to localhost, so treat header order as a hint rather than a rule.

Client hints can contradict the User-Agent. Chrome 156 headless sent a User-Agent with HeadlessChrome but sec-ch-ua brands of only Chromium and a placeholder brand. Branded Chrome also sends Google Chrome there. A check that compares the two headers finds a mismatch. Paste any User-Agent string into our user agent checker to see what it claims.

Declared AI agents name themselves. Assistants that fetch a page for a user send their own tokens:

Agent User-Agent token Notes
ChatGPT fetches ChatGPT-User/1.0 Published IP list; OpenAI says it isn't used for automatic crawling
Perplexity fetches Perplexity-User/1.0 Perplexity says it generally ignores robots.txt because a user asked
Claude fetches Claude-User Covered in our ClaudeBot guide

Agents that run a full cloud browser go further and sign requests with Web Bot Auth, which adds Signature, Signature-Input, and Signature-Agent headers under RFC 9421. Since July 1, 2026, Cloudflare treats signed agents as verified bots and labels each one Direct (one operator) or Intermediary (a service many end users can run). The agentic browser guide shows how ChatGPT's cloud browser publishes its keys.

For site owners, this is the cleanest detection there is: the agent tells you what it is, and you allow or block it by name. For agent builders, it means an honest identity can be worth more than a clean-looking browser.

Browser fingerprint signals the page can read

Once the page loads, its JavaScript can query the browser directly. We loaded a test page in four setups and recorded what it could read. All four ran headless on the same 2-core server with default options.

Grid of eight browser signals across four headless Chrome setups, showing which values read as automation tells, normal browser values, or host-dependent values

Signal Playwright default (headless shell 148) Playwright + Chrome 156 Same, AutomationControlled disabled Chrome 156 --headless, no driver
navigator.webdriver true true false false
HeadlessChrome in User-Agent Yes Yes Yes Yes
navigator.plugins.length 0 5 5 5
WebGL renderer SwiftShader SwiftShader SwiftShader SwiftShader
Timezone UTC UTC UTC UTC
Window vs viewport 1280×720 = 1280×720 1280×720 = 1280×720 1280×720 = 1280×720 780×580 outer, 780×380 inner
Notification.permission vs permissions query denied vs prompt default vs prompt default vs prompt default vs prompt

What each row means to a detector:

One check came back clean everywhere. A trick described in older detection write-ups logs an Error whose stack getter fires only when a DevTools client is listening. It returned false in all four setups, including the three driven by Playwright. Detection lore ages fast, in both directions.

This is why there's no such thing as an undetectable AI browser. Turning off one flag moved one row. The rest of the table, plus the IP and the behavior, still described a headless browser on a server. Patching more properties tends to create new contradictions between them, which is what fingerprinting scripts look for.

Desktop-style computer use agents score differently. In setups like Anthropic's reference container, the agent drives a headed browser with operating-system input events, so navigator.webdriver reports false and the window has real browser chrome. Our computer use agents guide shows what a site sees from that setup.

Behavior signals: how AI agents click and type

Page scripts can also record input events. We added listeners to the test page, then had Playwright call page.click() on a 120×40 px button and page.type() on a text field:

Exact centers and near-zero press times are easy for a behavioral model to separate from a person's input. LLM agents add a rhythm of their own on top: a pause of several seconds while the model reads a screenshot or the DOM, then a burst of actions, then another pause. When an action fails, an agent often repeats it immediately.

A newer idea tests for the model itself. Text the user can't see, such as an instruction hidden in the page, does nothing to a person or a scripted bot, but an LLM agent may act on it. Researchers used that idea, combined with response timing, to spot AI agents in an SSH honeypot. An agent that obeys hidden page text identifies itself, and it also has a security problem. Our AI agent security guide covers why prompt injection can't be filtered out.

Why an AI agent CAPTCHA appears, and what to do about it

CAPTCHAs today mostly score rather than test. reCAPTCHA v3 returns a score where 1.0 is very likely a good interaction and 0.0 is very likely a bot, and suggests 0.5 as a default threshold. Cloudflare Turnstile's managed mode chooses between a non-interactive check and a checkbox based on visitor risk. So a checkbox in front of your agent usually means the site wasn't sure. On many sites, a more confident score gets a block instead.

That also explains a widely shared moment from July 2025, when OpenAI's ChatGPT Agent clicked through Cloudflare's "Verify you are human" box. Clicking the box was never the test. The evidence had been collected before the click, and it was good enough for that visit.

When your agent meets a CAPTCHA, the right response is to stop and hand the page to a person, not to solve it. Wiring in CAPTCHA-solving services works against a check the site owner deliberately put there, and it usually breaks the site's terms. Our guide on whether data scraping is legal covers where that line usually sits. Retrying doesn't help either, because the score that produced the challenge hasn't changed.

To stop cleanly, the agent has to notice the challenge first. This check runs after each navigation or action in a Playwright agent:

from urllib.parse import parse_qs, urlsplit
from playwright.sync_api import sync_playwright

# widget iframe host -> path prefix
CHALLENGE_FRAMES = {"challenges.cloudflare.com": "/", "google.com": "/recaptcha/",
                    "recaptcha.net": "/recaptcha/", "hcaptcha.com": "/"}

def challenge_host(url):
    parts = urlsplit(url)
    host = parts.hostname or ""
    for domain, path in CHALLENGE_FRAMES.items():
        if (host == domain or host.endswith("." + domain)) and parts.path.startswith(path):
            return domain
    return None

# A passed widget writes its token into a *-response field in its own container.
# Turnstile keeps its iframe in a shadow root, so climb out through the host.
# Stop before <body>: puzzle frames hang off <body>, next to other widgets' tokens.
SOLVED = """iframe => {
  for (let node = iframe; node && node !== document.body;
       node = node.parentElement ?? node.getRootNode().host) {
    const fields = node.querySelectorAll('[name$="-response"]');
    if (fields.length) return [...fields].some((field) => field.value);
  }
  return false;
}"""

def human_check_on_page(page, response=None):
    """Return the host of a visible, unsolved human-check widget, or None."""
    if response is not None and response.headers.get("cf-mitigated") == "challenge":
        return "cloudflare challenge page"
    for frame in page.frames:
        if frame.parent_frame is None:
            continue  # the page itself, not a widget iframe
        host = challenge_host(frame.url)
        if host and "invisible" in parse_qs(urlsplit(frame.url).query).get("size", []):
            continue  # invisible reCAPTCHA badge; its puzzle opens in a separate frame
        element = frame.frame_element() if host else None
        box = element.bounding_box() if element and element.is_visible() else None
        if box and box["width"] >= 50 and box["height"] >= 50 and box["y"] >= 0:
            if not element.evaluate(SOLVED):
                return host
    return None

with sync_playwright() as p:
    browser = p.chromium.launch(headless=False)  # a person may need this window
    page = browser.new_page()
    response = page.goto("https://example.com/")
    page.wait_for_timeout(3000)  # widgets inject their iframe after the script loads
    if blocker := human_check_on_page(page, response):
        page.screenshot(path="needs-human.png")
        input(f"Paused: {blocker}. Finish the check in the browser window, then press Enter.")
    browser.close()

We tested it on local pages with the official test keys for Turnstile, reCAPTCHA v2, and hCaptcha. It flagged the visible widgets that still needed an answer. It ignored a plain page, a Turnstile widget that had already passed, and an invisible Turnstile widget, whose frame is 1×1 px. An invisible reCAPTCHA shows a 256×60 badge even when no challenge is open, so the check skips frames loaded with size=invisible and its hidden puzzle frame. It flags the puzzle once reCAPTCHA makes it visible. Add the hosts of any other CAPTCHA vendors you run into.

For the HTTP side, Cloudflare marks every challenge page with a cf-mitigated: challenge response header, which is what the first check in the function reads. Our Bot Fight Mode guide has a fetch wrapper that returns a block or challenge verdict to the model instead of the challenge HTML.

If you own the site and want to test your agent against your own forms, use the vendor's test keys. Cloudflare publishes dummy Turnstile sitekeys that always pass, always fail, or always show an interactive challenge.

Audit what your agent exposes

Run this with the same launch options and proxy your agent uses. It prints the TLS and HTTP/2 fingerprint the server saw and the browser signals from the table above:

import json
from playwright.sync_api import sync_playwright

SIGNALS = """async () => {
  const gl = document.createElement('canvas').getContext('webgl');
  const dbg = gl && gl.getExtension('WEBGL_debug_renderer_info');
  return {
    webdriver: navigator.webdriver,
    user_agent: navigator.userAgent,
    ua_brands: navigator.userAgentData?.brands.map(b => b.brand).join(', '),
    languages: navigator.languages.join(','),
    timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
    plugins: navigator.plugins.length,
    cpu_cores: navigator.hardwareConcurrency,
    screen: `${screen.width}x${screen.height}`,
    window: `outer ${outerWidth}x${outerHeight}, inner ${innerWidth}x${innerHeight}`,
    webgl: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : 'none',
    notifications: Notification.permission + ' / ' +
      (await navigator.permissions.query({name: 'notifications'})).state,
  };
}"""

with sync_playwright() as p:
    browser = p.chromium.launch()  # copy your agent's launch options and proxy here
    page = browser.new_page()
    page.goto("https://tls.browserleaks.com/json")
    tls = json.loads(page.inner_text("body"))
    report = {"ja4": tls["ja4"], "http2": tls["akamai_text"], **page.evaluate(SIGNALS)}
    browser.close()

for key, value in report.items():
    print(f"{key:14} {value}")

With Playwright 1.60's default browser on our server, it printed:

ja4            t13d1516h2_8daaf6152771_d8a2da3f94cd
http2          1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p
webdriver      True
user_agent     Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/148.0.7778.96 Safari/537.36
ua_brands      Chromium, HeadlessChrome, Not/A)Brand
languages      en-US
timezone       UTC
plugins        0
cpu_cores      2
screen         1280x720
window         outer 1280x720, inner 1280x720
webgl          ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)
notifications  denied / prompt

Read the output as a set. A residential exit with UTC, SwiftShader, and HeadlessChrome still describes a server. The fix that matters most depends on the site: identify the agent where the site supports signed or declared agents, slow the action rhythm, and use a full Chrome build instead of the headless shell when you need a browser at all. The shell differs from Chrome in its TLS handshake as well as in the page signals above.

Where proxies fit

A proxy changes exactly one layer: the network. It replaces a hosting ASN with an address from an ISP or a home connection, which removes the cheapest signal a site has for scoring an agent as automated. It does nothing for the TLS handshake, the headers, the browser fingerprint, or the behavior, and it doesn't change a site's decision to keep unidentified automation out.

For agents that need a stable identity, such as a partner site that allowlists your traffic or a logged-in session that shouldn't jump between addresses, a static ISP proxy gives you one fixed IP registered to a consumer ISP. For tasks that need many locations, rotating residential proxies spread requests across real household IPs. The proxies for AI agents guide covers which to choose and how to wire either into an agent.

FAQ

Can websites detect AI agents?

Yes, mostly the same way they detect other automation: hosting IPs, library TLS fingerprints, headless browser properties, and machine-like input. Agents add their own signals, such as declared user agents like ChatGPT-User, signed requests, and a think-then-burst action rhythm. Agents running in a person's own browser, on their own connection, are much harder to separate from that person.

Why does my AI agent keep getting CAPTCHAs?

Because the site's risk score for the visit landed in the uncertain range. Run the audit script above and check the IP. A cloud server IP, HeadlessChrome in the User-Agent, and software WebGL together are a common cause. A CAPTCHA that follows the agent across IPs points at the browser or behavior, not the network.

Can AI agents solve CAPTCHAs?

Some can click a checkbox, and a passing score does the rest. That doesn't make it appropriate. A CAPTCHA is the site asking for a person, so the agent should pause and hand the page to its user. If your agent has a take-over or confirmation step, this is the moment to use it.

Is there an undetectable AI browser?

No. Changing one property, as our webdriver test showed, leaves the other signals in place and can add contradictions. A browser that a site can't tell from a person usually means a real person's browser on a real connection. That is how local agentic browsers work, and it puts the user, not the agent, on the hook for what it does.

What user agent does ChatGPT use?

User-initiated fetches send ChatGPT-User/1.0 with a link to OpenAI's bot page. OpenAI publishes the IP ranges for it. Search indexing uses OAI-SearchBot and training crawls use GPTBot. ChatGPT's cloud browser signs its requests with Web Bot Auth instead of relying on the User-Agent.

Will a residential proxy stop AI agent CAPTCHAs?

Only when the IP was the main reason for the score. In our tests the browser carried several tells that no proxy touches. Fix those first, then use a proxy so the network layer agrees with the rest of the visit.

Final thoughts

An AI agent CAPTCHA is the end of a chain of evidence: a hosting IP, a library or headless handshake, HeadlessChrome in the headers, SwiftShader and navigator.webdriver in the page, and clicks that land on exact centers with presses under 1.5 ms. Knowing how websites detect AI agents tells you which of those you can fix and which you shouldn't try to hide. Run the audit against your own agent, stop and hand off on the first challenge, and identify the agent wherever the site gives you a way to. Then choose the network layer to match, with an ISP or residential proxy where a server IP is the weak link.

About the Author

Unknown Proxies

Proxy Infrastructure Team

Stay Unknown

High-performance dedicated proxies optimized for speed and reliability. Get uncompromising quality, 99.9% uptime, and unmatched support. Stay Unknown.

Explore Plans