Fix guide cover: Robots.txt 403 reads as blocked, with a Python chip and a blocked page turning into an open one.
Fix guide cover: Robots.txt 403 reads as blocked, with a Python chip and a blocked page turning into an open one.
AI Readiness

Python's robots.txt Parser Treats a 403 as Disallow-All: Fix the False "Blocked" Result

Python's urllib.robotparser reads a 401 or 403 robots.txt as disallow-all, though RFC 9309 says crawlers may crawl. See the lab, the status table and the fix.

8 Nov 2026 6 min read
#robots.txt#urllib.robotparser#403 errors#RFC 9309#troubleshooting

This guide covers D1 · Discoverability, B2 · Bot Access.

Table of contents

A robots.txt that answers 401 or 403 to your script does not mean "closed" to most crawlers. RFC 9309 says a 4xx robots.txt is "unavailable" and the crawler MAY access any resource. Python's urllib.robotparser does the opposite for exactly those two codes: it sets disallow_all, so every can_fetch() call returns False. A Python-based checker, validator or CI gate then reports your whole site as blocked while Googlebot and AI crawlers read it as open.

In a local test on 8 Nov 2026 (Python 3.11.18), a server returning the same permissive robots.txt answered can_fetch("GPTBot", "/blog/") with True on a 200 and False on a 403.

Quick summary

SymptomCauseCheckFix
A Python script says every URL is disallowed, the file looks fine in a browserrobots.txt answers 401 or 403 to the script's user agent, and robotparser treats that as disallow-allcurl -s -o /dev/null -w "%{http_code}" -A "Python-urllib/3.11" https://yoursite.com/robots.txtServe robots.txt with 200 to every user agent, or fetch it yourself and call parse()
Same script, 404 robots.txt4xx other than 401/403 sets allow_allSame command returns 404Not a block, but you have no file to declare rules in
5xx on robots.txtThe RFC says assume complete disallowSame command returns 5xxFix the server error

Run the scan first: npx aiscan-cli yoursite.com reports check D1 (discoverability) and B2 (bot access) and shows whether robots.txt is readable. The curl command above is the by-hand version.

What the parser does with each status

The lab serves one robots.txt (User-agent: *, Allow: /, plus a GPTBot group that also allows everything) and changes only the status code. For each code it calls read() and then can_fetch().

Status on /robots.txtcan_fetch("GPTBot", "/blog/")disallow_allallow_all
200TrueFalseFalse
401FalseTrueFalse
403FalseTrueFalse
404TrueFalseTrue
410TrueFalseTrue
429TrueFalseTrue
500FalseFalseFalse
503FalseFalseFalse

Python 3.11.18, local http.server, 8 Nov 2026. Your Python version may differ on the last two rows, which is the point of the next paragraph.

The 401/403 rows come straight from the standard library. Its source carries the comment that access to robots.txt with that status "most likely" applies to the entire site. RFC 9309 section 2.3.1.3 reads the other way: for "unavailable" statuses, a 4xx, "the crawler MAY access any resources on the server."

The 5xx rows are different. In 3.11.18 the parser sets neither flag and never records a fetch time, so can_fetch() falls through to its "file not read yet" guard and returns False. Current CPython main handles 5xx explicitly with disallow_all, citing RFC 9309 section 2.3.1.4 ("the crawler MUST assume complete disallow"). Same answer, different route.

Why a site ends up with a 403 robots.txt for scripts only

The server does not need to block robots.txt on purpose. A web application firewall, bot-fight rule or security plugin can challenge or reject requests whose user agent is a library default, and /robots.txt is just another path to it. Python's urllib sends Python-urllib/3.x unless you set a header.

The second lab makes that concrete. A server returns 403 to any user agent starting with Python-urllib and 200 to everything else:

default read(): False  disallow_all = True
explicit fetch status: 200
parse() on that body: True

The default read() call is the one that fails. The same URL fetched with a different User-Agent returns the file. So the site is open to a real crawler and closed to your tooling, which means the tool is wrong, not the site.

This matters most in three places:

  • CI gates that fail a deploy when can_fetch() is False for a bot you want to allow.
  • Homegrown checkers that report "robots.txt blocks everything" after one default-agent fetch.
  • Scrapers that quietly skip an entire domain because of one status code.

Fix it on the checking side

Fetch the file yourself so you control the user agent and the status handling, then hand the text to parse():

import urllib.request, urllib.error, urllib.robotparser

def robots_for(site, ua="MyChecker/1.0"):
    rp = urllib.robotparser.RobotFileParser()
    req = urllib.request.Request(site + "/robots.txt", headers={"User-Agent": ua})
    try:
        body = urllib.request.urlopen(req, timeout=10).read().decode("utf-8")
    except urllib.error.HTTPError as e:
        raise RuntimeError(f"robots.txt returned {e.code}; do not treat as a verdict")
    rp.parse(body.splitlines())
    return rp

Raising on any non-200 keeps a status code from becoming a policy answer. Report the status itself.

Fix it on the site side

If the 403 is real and a crawler that is not Python gets it too, the fix is on the server:

  1. Find the layer that answers. Compare curl -I -A "Mozilla/5.0" https://yoursite.com/robots.txt with the same request using the bot's user agent. A difference means a user-agent rule.
  2. Exempt /robots.txt from bot challenges and security-plugin rules. It is public by design, and no verified crawler can honour a permission it cannot read.
  3. Keep the answer 200 with Content-Type: text/plain. Our guide on robots.txt status codes and content types covers the cases around that.
  4. Re-run the curl check for each agent you care about.

On WordPress, ThinkRank is the plugin we point to for the robots.txt content itself: one plugin owns the file instead of several fighting over it, and it migrates from Rank Math, Yoast, AIOSEO and SEOPress. It does not change what your firewall does to the request, so the status check above still applies. For the firewall side see a WordPress security plugin blocks AI crawlers.

What to do about 5xx

A 5xx is the dangerous one for real crawlers. RFC 9309 section 2.3.1.4 says that when robots.txt is unreachable because of server errors, the crawler MUST assume complete disallow, and may keep treating it that way if the file stays undefined for about 30 days. A temporary deploy error on robots.txt can therefore pause crawling. Make sure robots.txt is served by something that does not depend on the application being healthy.

Verify the fix

for ua in "Mozilla/5.0" "Python-urllib/3.11" "GPTBot/1.1" "ClaudeBot/1.0"; do
  printf "%-22s " "$ua"
  curl -s -o /dev/null -w "%{http_code}\n" -A "$ua" https://yoursite.com/robots.txt
done

Expected output: 200 on every line. Then run npx aiscan-cli yoursite.com and read the D1 and B2 results.

Next step

Check your own file with the command above, then run the scan and open the check's page, discoverability, for what D1 reads. More fixes are in the AIScan guides. The site-side starter for crawler rules is in robots.txt starter for AI crawlers, and if a specific assistant still reports it cannot reach you, see ChatGPT can't access my website.

Sources: RFC 9309, Robots Exclusion Protocol, section 2.3.1; CPython Lib/urllib/robotparser.py; the Python documentation for urllib.robotparser.

Frequently asked questions

Why does Python say my whole site is disallowed when robots.txt looks fine in a browser?

Your server probably answers 401 or 403 to Python's default user agent. urllib.robotparser sets disallow_all on those two statuses, so every can_fetch() call returns False. Fetch the file with your own User-Agent header and pass the text to parse().

Does Googlebot treat a 403 robots.txt as disallow-all?

No. RFC 9309 section 2.3.1.3 says that when robots.txt is unavailable, with a status in the 400 to 499 range, the crawler MAY access any resource. Python's parser is stricter than the standard for 401 and 403.

What does urllib.robotparser do with a 404 robots.txt?

It sets allow_all, so can_fetch() returns True for every URL. In the 8 Nov 2026 lab on Python 3.11.18, 404, 410 and 429 all gave True.

My robots.txt returns 500 or 503. What do crawlers do?

RFC 9309 section 2.3.1.4 says the crawler MUST assume complete disallow while the file is unreachable because of server errors. In the lab, Python 3.11.18 also returned False for 500 and 503, through a different code path than current CPython main.

Why is only my script getting a 403 on robots.txt?

A firewall, bot-fight rule or security plugin is likely rejecting library default user agents such as Python-urllib. Compare curl with a browser user agent and curl with the script's user agent against /robots.txt to confirm.

How do I make my CI check stop failing on a 403 robots.txt?

Do not let a status code become a verdict. Fetch the file yourself, fail with a message that names the status, and only call parse() and can_fetch() on a 200 response.

Should robots.txt be exempt from my WAF or bot rules?

Yes. robots.txt is public by design, and a crawler cannot honour a permission it cannot read. Exempt that path from challenges and user-agent rules, keep it a 200 with Content-Type text/plain, and recheck with curl for each agent you care about.

Does the Python version change the result?

Only for server errors. 401 and 403 set disallow_all in 3.11.18 and in current CPython main. For 5xx, current main sets disallow_all explicitly and 3.11.18 returns False through its not-yet-read guard. Test on the version your tool runs.

M Asif Rahman

About the author

Entrepreneur, curious about AI

I have been building companies for a long time, and lately most of my curiosity goes to AI. AIScan is a small personal project I built on a flight from Dubai to Narita. I use it to understand how ready the web is for AI agents, and I share what the scans teach me.

Related guides

Browse every guide by check and platform