# 403 Forbidden: four layers can send it, and each one leaves a fingerprint

> Cloudflare's "Sorry, you have been blocked", the web server, a security plugin or WordPress: four layers send a 403 Forbidden. How to tell which one sent yours.

Source: https://visibleorigin.com/403-forbidden-which-layer/
Published: 2026-10-22

---

**The short version.** A 403 Forbidden says that something between the visitor and the page refused the request. It does not say what. On a WordPress site behind a CDN, four layers can send one: the CDN’s firewall, the web server, a security plugin and WordPress itself. I made each of them answer 403 on my own site, and each left a different fingerprint in the page it returned, in two response headers and in whether the request ever reached my server’s log. Of the 297 real 403s my server logged in 18 days, 294 came from the security plugin.

![403 Forbidden from four layers: Cloudflare, LiteSpeed, Wordfence and WordPress, what each said and if it was logged](https://visibleorigin.com/wp-content/uploads/403-forbidden-four-layers.webp)Same status code, four different authors. The page each one returns names it, and only one of them never reaches the server’s log.

Most guides to the 403 Forbidden error give you a list of fixes: check file permissions, clear the cache, disable your plugins one by one, ask your host. That list is the right one only if you already know which layer refused the request, and the status code is the one thing that never tells you. The same three digits come back whether a firewall on another continent blocked you or a plugin on your own server did.

So I went looking for the fingerprints instead. On October 20, 2026, I sent harmless requests to my own site, visibleorigin.com, through Cloudflare and straight to the server, until each layer had refused one. Then I compared what came back and checked which requests my server had logged. This note is what I found, and a way to do the same on your site in a few minutes.

## What a 403 Forbidden actually says

The definition is short. RFC 9110, the specification of HTTP, says that “the 403 (Forbidden) status code indicates that the server understood the request but refuses to fulfill it.” The next sentence matters more for this note: “A server that wishes to make public why the request has been forbidden can describe that reason in the response content (if any).”

Two words in those sentences carry the whole problem. The first is “server.” For the visitor, the server is whatever answered, and on a modern site several programs stand in line before the page: a CDN with its firewall, the web server, often a security plugin, and then the application. Any of them can answer, and the answer looks the same from the status line. The second word is “can.” A layer may explain itself in the content of the response, and in practice every layer I tested did, each in its own words. **The status code tells you that someone refused. The body tells you who.**

It also helps to know what a 403 is not. The same specification keeps 401 for requests that lack valid credentials, and WordPress follows that line strictly. Its REST API decides between the two codes with a one-line function, `rest_authorization_required_code()`, which returns 403 only when someone is logged in and 401 when nobody is. When I asked my site for its settings and its user list without logging in, WordPress answered 401 both times, with a JSON body whose `code` field said `rest_forbidden` and `rest_user_cannot_view`. So if an anonymous visitor gets a 403 from a WordPress site, the REST API is the least likely author.

## I made each layer of my own site say 403

My stack is a common one: Cloudflare in front, LiteSpeed as the web server, the Wordfence firewall loaded before WordPress, and WordPress itself. I sent plain GET requests with a user agent I could find later in the log, and chose addresses that each layer refuses on purpose. None of them changes anything on the site, and all of them are things scanners request every day. These are the four 403s, all requested through Cloudflare:

| Request | Who refused it | What gave it away |
| --- | --- | --- |
| `/.env` | **Cloudflare**, with a firewall rule of mine | “Sorry, you have been blocked” and a Ray ID in the page; no `cf-cache-status` header; not in my server’s log |
| `/.htaccess` | **LiteSpeed**, the web server | “Access to this resource on the server is denied!” and “Proudly powered by LiteSpeed Web Server”; `cf-cache-status: BYPASS` |
| `/wp-admin/install.php` | **Wordfence**, the security plugin | “A potentially unsafe operation has been detected in your request to this site” and a “Block Technical Data” section; `cf-cache-status: DYNAMIC` |
| `/wp-mail.php` | **WordPress** itself | A page titled “WordPress › Error” that says “This action has been disabled by the administrator.”; `cf-cache-status: BYPASS` |

Requests sent from this site’s server on October 20, 2026, between 22:37 and 22:42 UTC. The pages weighed 4,548 bytes (Cloudflare), 1,240 (LiteSpeed), 17,008 (Wordfence) and 2,571 (WordPress). The last three were in the server’s access log; the first was not.

Four layers, four different pages, and not one of them pretends to be another. That alone settles most cases: **the wording on the page names the layer**, and you can read it with your own eyes before running any command.

The more interesting result came from asking the same addresses twice: once through Cloudflare, and once straight to the server, skipping the edge. For several of them, the two answers disagreed.

![Five addresses asked through Cloudflare and straight to the server: three got different answers, two the same 403](https://visibleorigin.com/wp-content/uploads/403-same-address-two-answers.webp)The layer closest to the visitor answers first. Asked directly, the server would have said something else for three of these five addresses.

Asked for `/.env`, Cloudflare answered 403, and my server, asked directly, answered 404: the file does not exist, and WordPress said so with its normal “not found” page. The same happened with `/wp-login.php`, which Cloudflare blocks and the server would have answered with a 404, because the login page on this site lives at another address. `/xmlrpc.php` got a 403 from both, but two different ones: Cloudflare’s 4,548-byte block page, and from the server a 34-byte line of plain text, “XML-RPC is disabled on this site.”, written by a small plugin of mine. Only the outer one ever reaches a visitor. **The layer closest to the visitor answers first, and the layers behind it never get asked.**

## The four fingerprints, layer by layer

Each layer leaves marks in three places: the body of the response, a couple of headers, and the logs that do or do not contain the request. Here is what each one looked like on my site, and what to look for on yours.

### Cloudflare: “Sorry, you have been blocked” and a Ray ID

Cloudflare’s block page is easy to recognize once you have seen it. Its title is “Attention Required! | Cloudflare,” its heading says “Sorry, you have been blocked,” and at the bottom it prints a **Cloudflare Ray ID**, the identifier Cloudflare gives every request. Cloudflare describes it as “an identifier given to every request that goes through Cloudflare” and says Ray IDs are “particularly useful when evaluating Security Events for patterns or false positives.” If you own the site, that Ray ID takes you straight to the event in the dashboard, with the rule that matched.

Cloudflare’s own troubleshooting page for the error draws the same line. It lists the cases where Cloudflare itself serves a 403, starting with firewall rules, custom or managed, “with the challenge or block action,” and its Security Level settings. And for a 403 page without Cloudflare’s branding, it says “this means that the error is being returned directly by the origin web server, not Cloudflare.”

The headers say it too, although not the one people check first. The `server` header said `cloudflare` on every response that went through the edge, including the 403s that came from my own server, so it proves nothing. **The useful one is `cf-cache-status`.** On my zone, the blocked responses did not have it at all, while every 403 that came from behind Cloudflare carried one. That matches Cloudflare’s documentation on cache responses, which says that for a request its firewall blocked, “Since there is no cache status, Cloudflare will log as none/unknown.” A smaller tell: the block page carried Cloudflare’s own security headers, such as `referrer-policy: same-origin`, where every page from my server says `strict-origin-when-cross-origin`.

The strongest test of all is the server’s log. A request that Cloudflare blocked never reaches your server, so **no line in your access log is itself the evidence**. My `/.env` and `/wp-login.php` requests through the edge left no trace in my server’s log, while the same addresses asked directly did. I measured how far that blind spot goes once already, when [I made 60 requests to my own site and the access log recorded 6](https://visibleorigin.com/access-log-cannot-see-the-edge/).

One variant looks different. When Cloudflare challenges a visitor instead of blocking them, the response carries a specific header; its documentation says a challenged response “will have the `cf-mitigated` header present and set to `challenge`,” and that “challenge is the only valid value.” A real browser usually passes the challenge without the visitor noticing. A script, a monitoring tool or a crawler that cannot run it stays stuck at that page.

### The web server: its own default page

When LiteSpeed refuses a request, it answers with its built-in page: a title of “403 Forbidden,” the sentence “Access to this resource on the server is denied!” and a footer that reads “Proudly powered by LiteSpeed Web Server.” The page came back byte for byte the same through Cloudflare and straight from the server, and through Cloudflare it carried `cf-cache-status: BYPASS`, which Cloudflare defines as an asset it “considered eligible for cache at request time” whose origin response “was ultimately not cacheable.”

On my server, the web server refused files it protects by design, such as `/.htaccess`. That is the typical source of a server-level 403: rules about which files may be served, and file or folder permissions. Other servers use their own default pages, and the wording usually names the software. A web server 403 is also the easiest to confirm from the inside: LiteSpeed wrote both of my `/.htaccess` refusals to its own error log, as “[ACL] Access to context [/] is denied!”, while the Wordfence and WordPress refusals left nothing there.

One result surprised me. A request for a folder without an index page, `/wp-includes/` or `/wp-content/uploads/`, did not get a 403 on my server, as many guides would predict. It got a 404, through Cloudflare and directly. **Server defaults differ, and that is exactly why the fingerprint is worth more than a list of usual suspects.**

### The security plugin: Wordfence explains itself

Wordfence’s firewall runs inside PHP but before WordPress, so it can refuse a request before any page is built. Its block page is the longest of the four, 17,008 bytes on my site, and the most explicit: “A potentially unsafe operation has been detected in your request to this site,” then “Your access to this service has been limited. (HTTP response code 403),” and a “Block Technical Data” section with the reason and the time of the block. The template that prints it ships with the plugin; the copy on my server is identical to the one in Wordfence 9.0.2 on WordPress.org.

Through Cloudflare, this page carried `cf-cache-status: DYNAMIC`, meaning “Cloudflare determined at request time that the asset is not eligible for cache.” The request was in my server’s access log with a 403, and Wordfence also keeps its own record, under its firewall and live traffic screens, with the rule that fired. **If the page a visitor describes mentions “Block Technical Data,” you know which dashboard to open.**

### WordPress itself: “WordPress › Error”

WordPress has its own way of saying no: a small page built by a function called `wp_die()`, titled “WordPress › Error,” with one sentence in the middle. The 403 I got came from `wp-mail.php`, a file in every WordPress install that publishes posts sent by email. When that feature is not configured, the file stops at its first lines and calls `wp_die()` with “This action has been disabled by the administrator.” and a 403. On my site that page weighed 2,571 bytes and went through Cloudflare with `cf-cache-status: BYPASS`.

Plugins and themes can answer 403 too, in any format their authors chose. My own plugin that switches off XML-RPC answers with one line of plain text. Inside the REST API, WordPress answers in JSON, and as shown above it uses 403 only for logged-in users who lack a permission. **If the body of a 403 is a short sentence, a JSON object or a bare `-1`, look at the code running on your site before you look at the firewall.**

## Who sent the 403s my server logged in 18 days

Triggering each layer once shows what is possible. The access log shows what actually happens. Between October 2 and October 20, my server answered 297 requests with a 403, not counting my own tests. I matched each one against the paths Wordfence had recorded as blocked in the same period, and against the files the web server refuses.

![Bar chart of 297 refusals in my server log: Wordfence 294, LiteSpeed 2, WordPress 1; Cloudflare blocks never arrived](https://visibleorigin.com/wp-content/uploads/403-who-sent-them.webp)Almost every 403 my server sent came from the security plugin. Whatever Cloudflare blocked is not in this picture, because it never arrived.

The result was lopsided. **Wordfence sent 294 of the 297.** LiteSpeed sent 2, both for `/.htaccess`, and WordPress sent 1, for `/wp-mail.php`. Most of Wordfence’s blocks were for a single address: 237 requests for `/wp-admin/install.php`, the installer, which scanners request hoping to find a site whose setup was never finished. Another 12 were POST requests to `/wp-admin/admin-ajax.php` and 6 went to the test endpoint of a mail plugin this site does not even use. The remaining 39 asked for files this site has never had: 28 for backdoors with names like `cache.php` buried under repeated folders, and most of the rest for files of vulnerable plugins that were never installed here.

The figure has a fourth bar that I cannot fill in: the requests Cloudflare blocked never reached the server, so this log cannot count them. My firewall rule for addresses like `/.env` and `/.git/config`, and the ones for the login page and XML-RPC, act before any of the numbers above. Cloudflare’s Security Events screen is the only place where they add up. A site that sees few 403s in its server log may simply have an edge that catches most of them first.

Two things this log can vouch for. None of those 297 refusals was for a page a reader would ask for: every one was for a file or an address that only a scanner requests. And none of them went to a search engine. Between August 28 and October 20, my logs hold 658 requests with Googlebot in the user agent and 193 with Bingbot. Exactly one of them got a 403: a request for `/userfiles?path=../../../../proc/self/environ` on September 12, from an address with no reverse DNS at all. **That was not Googlebot.** It was someone wearing its name, and it was refused for the right reason.

## How to find which layer sent your 403

Four checks, in this order. The first two take seconds and need nothing but a terminal; the last two need access to the site.

### 1. Read the page, not the code

```
curl -s 'https://example.com/the-page/' | grep -i -o '<title>[^<]*'
```

The title alone separated all four of my layers: “Attention Required! | Cloudflare,” “403 Forbidden” with LiteSpeed’s footer below it, “403 Forbidden” with Wordfence’s sentence, and “WordPress › Error.” If the title is generic, read the first lines of the body. Every layer I tested signed its page somewhere.

### 2. Read two headers, with GET

```
curl -s -o /dev/null -D - 'https://example.com/the-page/' \
  | grep -i -E '^(cf-ray|cf-cache-status|cf-mitigated|server):'
```

A `cf-ray` header means the request went through Cloudflare. If `cf-cache-status` is there too, the answer came from behind Cloudflare, from your server or something on it; **if it is missing, Cloudflare answered on its own**. `cf-mitigated: challenge` means a challenge, not a block. Use a normal GET as in the command above, not `curl -I`: a HEAD request can take a different path through caches and rules than the GET a browser sends, and on my own site the two have disagreed about the cache before.

### 3. Look for the request in your server’s log

```
grep ' 403 ' /path/to/access.log | grep '/the-page/' | tail -5
```

A line with your request and a 403 means your server, or something running on it, refused it: go to the next check. No line at all, for a request you know you made, means it was refused before it arrived. On a site behind Cloudflare, that is the edge.

### 4. Open the record of the layer that answered

Each layer keeps its own account of what it refused. In Cloudflare, Security Events, filtered by the Ray ID from the block page. In Wordfence, the firewall and live traffic screens, which name the rule. For the web server, its error log. For WordPress, the plugin or file that called `wp_die()`. The fix depends on what you find there: a rule that is too broad, a permission that is too tight, a plugin that refuses more than it should. I once found that [my own firewall rule would have blocked my own article about firewall rules](https://visibleorigin.com/firewall-rule-blocks-own-article/), because it matched a word that also appears in normal content. A rule that matches parts of addresses can do that to any site.

If the trail ends between your server and the visitor, in a CDN rule, a cache or a firewall nobody remembers adding, that layer is [the technical SEO and server work I do](https://visibleorigin.com/services/), and finding which layer said no is usually most of the job.

## When the 403 Forbidden is for Googlebot or Bingbot

A 403 served to a search engine has a direct cost. Google’s documentation on status codes is blunt: “Google doesn’t index URLs that return a 4xx status code,” and 403 is in that group. The same page warns site owners not to use it as a brake: “Don’t use 401 and 403 status codes for limiting the crawl rate,” because “the 4xx status codes, except 429, have no effect on crawl rate.” **A 403 does not slow a crawler down. It takes the page out of the results.** Since September 15, 2026, one Cloudflare setting can send that 403 to Googlebot by design, which I traced in [what Cloudflare’s Block and Disallow AI Training settings stop](https://visibleorigin.com/cloudflare-ai-crawl-control/).

The trap is in how people check. **Sending a request with Googlebot’s user agent from your laptop tells you what your laptop gets, not what Googlebot gets.** Firewalls and security plugins that care about crawlers check the address, not the name, which is how I check the crawlers in my own logs: [by their published IP ranges instead of their user agents](https://visibleorigin.com/ai-crawlers-verified-by-ip/). The reverse is just as true: a request from a cloud server can be refused where a person in a browser is welcome. When [I measured what my free check can reach from the datacenter it runs in](https://visibleorigin.com/free-check-runs-from-a-datacenter/), five well-known sites refused it outright, whatever user agent it claimed. The tools that see what a crawler sees are the search engines’ own: the live test in Google Search Console’s URL Inspection, and the live URL test in Bing Webmaster Tools.

And when a search engine’s own tool reports a 403 that is not about a page at all, read its body as you would any other. Bing’s IndexNow API, for example, answers a key it cannot verify with a 403 and a sentence explaining why, which I took apart in [what “Error submitting page to IndexNow” hides](https://visibleorigin.com/error-submitting-page-to-indexnow/). Same three digits, different layer, different fix.

## 403 Forbidden: frequently asked questions

### What does 403 Forbidden mean?

It means the server understood the request and refuses to fulfill it. On a site behind a CDN, “the server” can be any layer that answers: the CDN’s firewall, the web server, a security plugin or the application. The page that comes with the 403 usually says which one, and the reason if that layer chose to give it.

### What is the difference between 401 and 403?

A 401 means the request lacks valid credentials, so logging in may fix it. A 403 means the server understood the request and refuses it anyway; other credentials help only if the refusal was about who you are. WordPress applies this strictly in its REST API: it answers 401 to anonymous visitors and 403 only to logged-in users who lack a permission.

### How do I know if Cloudflare is the one blocking me?

Look for three signs: a page that says “Sorry, you have been blocked” with a Cloudflare Ray ID at the bottom, a response without a `cf-cache-status` header, and no trace of the request in the site’s own access log. If you own the site, search for the Ray ID in Cloudflare’s Security Events to see which rule blocked it.

### Why do I get a 403 on one page but not on others?

Because most rules that send a 403 match addresses, not visitors. A firewall rule can match part of a path, a security plugin can protect specific files, and a web server can refuse certain file names. Compare the address that fails with the rules of the layer that answered: the match is usually literal.

### Does a 403 Forbidden hurt SEO?

Yes, for the pages that return it. Google says it doesn’t index URLs that return a 4xx status code, and 403 is one of them. It also says not to use 403 to limit crawling, because it has no effect on crawl rate. Check what crawlers receive with Search Console’s URL Inspection and Bing’s live URL test, not with a faked user agent.

### Why does curl get a 403 when my browser does not?

Because a script and a browser look different to a firewall: different user agent, different behavior, and often a different IP address, such as a server in a datacenter. A challenge page that a browser solves silently stops a script cold, and that shows up as a `cf-mitigated: challenge` header on Cloudflare sites.

## The general shape of this

A status code is a verdict without a signature. In a stack of layers, the number travels unchanged from whichever layer decided, and the signature is in the parts people skip: the body, two headers, and the log line that is there or is not. I keep meeting the same shape on this site. [A 301 told me less about a page than it seemed to](https://visibleorigin.com/a-redirect-does-not-prove-a-page-exists/), and the 403 says less about who refused than it seems to. The remedy is always to read the whole response once, by hand, and then find the record of the layer that answered.

If you would rather have someone else do that reading for your homepage, start with [the free check](https://visibleorigin.com/services/#free-check): it requests the page as Googlebot, Bingbot and three AI crawlers, one at a time, and shows you the status code each one received, with the commands to repeat it yourself.

## Sources

- [RFC 9110, section 15.5.4, 403 Forbidden](https://www.rfc-editor.org/rfc/rfc9110.html#name-403-forbidden): the definition and the sentence about describing the reason in the response content.
- [Google for Developers, How HTTP status codes affect Google’s crawlers](https://developers.google.com/crawling/docs/troubleshooting/http-status-codes): 4xx URLs are not indexed, and 401 and 403 are not for limiting the crawl rate.
- [Cloudflare, Error 403](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/4xx-client-error/error-403/): when Cloudflare itself serves a 403, and that a 403 without its branding comes from the origin web server.
- [Cloudflare, Cloudflare cache responses](https://developers.cloudflare.com/cache/concepts/cache-responses/): the meaning of `BYPASS` and `DYNAMIC`, and no cache status for requests the firewall blocked.
- [Cloudflare, Cloudflare Ray ID](https://developers.cloudflare.com/fundamentals/reference/cloudflare-ray-id/): what a Ray ID is and its use in Security Events.
- [Cloudflare, Detect a Challenge Page response](https://developers.cloudflare.com/cloudflare-challenges/challenge-types/challenge-pages/detect-response/): the `cf-mitigated: challenge` header.
- [Wordfence 9.0.2, wf-waf/src/views/403.php](https://plugins.svn.wordpress.org/wordfence/tags/9.0.2/vendor/wordfence/wf-waf/src/views/403.php): the template of the firewall’s block page, identical to the copy on my server.
- [WordPress Code Reference, rest_authorization_required_code()](https://developer.wordpress.org/reference/functions/rest_authorization_required_code/): the function in `wp-includes/rest-api.php` that returns 403 only for logged-in users.
- [WordPress, wp-mail.php](https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-mail.php): the 403 with “This action has been disabled by the administrator.” when posting by email is not configured.
- My own measurements, October 20, 2026: requests to this site through Cloudflare and straight to the server, its access log from October 2 to 20, the crawler log from August 28 to October 2, and Wordfence’s record of blocked requests.
