TIL: A 403 Response Doesn't Mean Your Application Returned 403
A 403 in the browser doesn't necessarily come from your application. I learned to trace requests through CDN, WAF, reverse proxy, web server, and application layers before debugging the code that may never have received them.
I recently spent some time debugging a 403 Forbidden response from a WordPress endpoint.
My first reaction was predictable.
Something in WordPress must be rejecting the request.
Maybe permissions were wrong. Maybe a plugin was interfering. Maybe the REST API had some authentication rule I'd forgotten about.
So I started looking at the application.
That turned out to be the wrong place.
The request wasn't reaching WordPress at all.
It was being blocked earlier.
That sounds obvious once you know the answer, but it made me realise that I've probably treated HTTP responses as application responses more often than I should have.
A browser showing:
403 Forbidden
only tells me what response came back.
It doesn't tell me who generated it.
What 403 actually tells us
HTTP 403 Forbidden has a pretty simple meaning.
The server understood the request but refuses to fulfill it.
What that definition doesn't tell us is which server we're talking about.
A modern production request might pass through something like:
Browser
|
v
DNS
|
v
CDN / Edge
|
v
Web Application Firewall
|
v
Load Balancer / Reverse Proxy
|
v
Web Server
|
v
PHP / Node.js / Application Server
|
v
Application
Any one of those layers can reject the request.
So this:
GET /wp-admin/admin-ajax.php
|
v
403 Forbidden
doesn't automatically mean:
WordPress returned 403
It might actually mean:
GET /wp-admin/admin-ajax.php
|
v
WAF
X
|
403 Forbidden
WordPress never saw it
That distinction matters a lot.
The application may never receive the request
This was the part that changed how I approached the problem.
Imagine an endpoint like:
/wp-admin/admin-ajax.php?action=get_data&id=5
The frontend calls it and receives:
HTTP/2 403
If I assume the problem is WordPress, I might start checking:
current_user_can(...)
or:
wp_verify_nonce(...)
I might inspect plugin hooks, authentication filters, REST permissions, or admin-ajax.php.
All reasonable things to check.
Except none of them matter if a WAF blocks the request first.
For example, Azure Front Door's WAF can block a matching request and return the response directly without forwarding that request to the origin at all. Its default response for a WAF block is 403.
At that point, debugging PHP is pointless.
The PHP process never ran.
The first thing I check now
Instead of immediately opening the application code, I start with the actual HTTP response.
Usually:
curl -i https://example.com/some-endpoint
or, if I'm only interested in the headers:
curl -I https://example.com/some-endpoint
Sometimes I also want the verbose output:
curl -v https://example.com/some-endpoint
The response headers can give away quite a lot.
For example:
HTTP/2 403
server: nginx
content-type: text/html
x-cache: CONFIG_NOCACHE
x-azure-ref: 20260911T...
Or perhaps:
HTTP/2 403
server: cloudflare
cf-ray: ...
Those headers don't always prove exactly where the response originated, but they're clues.
And clues are much more useful than immediately blaming the application.
Compare the response body too
The body can also help.
Maybe the application normally returns JSON:
{
"code": "rest_forbidden",
"message": "Sorry, you are not allowed to do that."
}
But the failing request suddenly returns:
<html>
<body>
The request is blocked.
</body>
</html>
That's suspicious.
If an API normally speaks JSON and suddenly answers with a generic HTML error page, there's a good chance another layer generated the response.
Not guaranteed.
But enough to change where I'd look next.
Some infrastructure products make this even clearer. Azure Front Door WAF, for example, includes a tracking reference in its default blocked response so the request can be matched with log entries.
Cloudflare documents a similar distinction. A 403 can originate from Cloudflare security features or directly from the origin server, depending on what handled the request.
The same status code doesn't mean the same component produced it.
Try to prove whether the application saw the request
This is probably the most useful debugging question:
Did the request reach my application?
Not:
Why did my application return 403?
Those are very different questions.
There are several ways to find out.
If I control the application, I can temporarily add logging around the endpoint.
For WordPress:
add_action('init', function () {
if (
isset($_SERVER['REQUEST_URI']) &&
str_contains($_SERVER['REQUEST_URI'], 'admin-ajax.php')
) {
error_log(
'[debug-request] ' .
$_SERVER['REQUEST_METHOD'] . ' ' .
$_SERVER['REQUEST_URI']
);
}
});
I wouldn't leave something like that running forever on a busy site.
For debugging, though, it's useful.
Now I send the failing request again.
If my application log shows:
[debug-request] GET /wp-admin/admin-ajax.php?action=get_data
then I know the request reached WordPress.
If there's nothing there, I move one layer outward.
Web server logs can help too:
grep "admin-ajax.php" access.log
If nginx or Apache saw the request but WordPress didn't, the problem might be somewhere between the web server and PHP.
If the web server didn't see it either, I keep moving toward the edge.
That's much faster than randomly changing application code.
Work backwards through the stack
I've started thinking about these issues as a path.
Something like:
Client
↓
CDN
↓
WAF
↓
Reverse proxy
↓
Web server
↓
Application
When a request fails, I want to find the last layer that definitely saw it.
For example:
Client YES
Azure Front Door YES
WAF YES
Origin NO
WordPress NO
That's already enough information.
I don't need to inspect WordPress plugins.
I need to inspect the WAF.
Another case might look like:
Client YES
CDN YES
WAF YES
nginx YES
PHP YES
WordPress YES
Now it's reasonable to investigate application permissions.
The same browser error can lead to completely different debugging paths.
Reproduce the exact request
There's another trap here.
I'd sometimes test an endpoint with:
curl https://example.com/api
and get:
200 OK
Then the browser would still receive:
403 Forbidden
At first that seems inconsistent.
Usually it isn't.
The two requests aren't actually the same.
A WAF might be checking:
HTTP method
query parameters
request body
headers
cookies
user agent
source IP
request path
So this:
curl https://example.com/api
may work.
While this:
curl \
-X POST \
-H "Content-Type: application/json" \
-d '{"query":"something"}' \
https://example.com/api
may trigger a rule.
Even two GET requests can behave differently:
/api/search?q=hello
versus:
/api/search?q=SELECT something
A security rule might interpret part of the second request as suspicious.
That's why reproducing the actual request matters.
Browser DevTools makes this easy.
I can open the Network panel, right-click the request and copy it as cURL.
That gives me something much closer to what the browser really sent:
curl 'https://example.com/wp-admin/admin-ajax.php?action=get_data' \
-H 'accept: */*' \
-H 'content-type: application/x-www-form-urlencoded' \
-H 'origin: https://example.com' \
-H 'referer: https://example.com/page/' \
--data-raw 'table_id=5&something=value'
Now I'm debugging the same request.
Not an approximation of it.
A WAF can block completely legitimate traffic
This was another useful lesson.
A WAF rejecting something doesn't automatically mean the request is malicious.
False positives happen.
A query parameter, header, cookie, JSON property or form field can contain a string that happens to match a security rule.
Microsoft's own documentation gives an example where legitimate API headers trigger a SQL injection rule and need a carefully scoped WAF exclusion.
That's an important distinction.
The wrong fix would be:
Disable WAF
A better fix is usually much narrower.
Maybe one rule needs tuning.
Maybe one request argument needs an exclusion.
Maybe a specific path should behave differently.
Or maybe the application can send the data in a form that doesn't accidentally trip the rule.
The goal isn't to make the 403 disappear at any cost.
It's to understand why it exists.
Bypassing a layer can be a very useful test
When the infrastructure allows it safely, testing the origin directly can tell you a lot.
Suppose:
https://www.example.com/api
goes through:
Front Door -> WAF -> origin
But I can also reach the origin from an internal network or another controlled path.
If:
Public URL -> 403
while:
Origin -> 200
I've narrowed the search considerably.
It doesn't prove the WAF is responsible.
The CDN, routing rules, proxy configuration or some header difference could still be involved.
But I now know the application itself can handle the request.
That's useful.
The reverse test is useful too.
If the origin itself returns 403, blaming the CDN doesn't make much sense.
Don't forget different HTTP methods
I ran into another version of this problem with endpoints where:
GET /endpoint
worked, while:
POST /endpoint
returned 403.
Again, it's tempting to assume the POST handler is broken.
But security layers often treat methods differently.
The same can happen with:
GET
POST
PUT
PATCH
DELETE
OPTIONS
An OPTIONS request failing can create what looks like a CORS problem.
A POST body might trigger a WAF rule while a GET request to the same URL works perfectly.
So testing only the URL isn't enough.
The method is part of the request.
Authentication can also exist at several layers
A 403 involving authentication gets even more confusing.
The application might have its own authentication:
WordPress session
JWT
OAuth
API key
But infrastructure can add another set of restrictions:
IP allowlist
client certificate
Front Door rule
WAF rule
reverse proxy authentication
network restriction
Imagine an API key is perfectly valid.
The application would accept it.
But the WAF blocks the client's IP.
The application never gets the chance to validate that API key.
From the client side, though, it still looks like:
403 Forbidden
That's why a status code alone doesn't describe the whole failure.
My debugging order changed
I used to debug problems like this roughly from the inside out:
application
plugin
framework
web server
infrastructure
Now I usually do almost the opposite.
First, inspect the response.
Then reproduce the exact request.
Check edge or WAF logs if they're available.
Check whether the origin saw the request.
Check the web server.
Only then dig into the application.
Something like:
1. What exactly did the client send?
2. What exactly came back?
3. Which layer produced that response?
4. Did the origin receive the request?
5. Did the application receive it?
6. If yes, what did the application do with it?
That sequence has saved me quite a bit of pointless debugging.
The status code is the symptom
I think that's the main thing I learned.
When I see:
403 Forbidden
the question isn't immediately:
Why is WordPress returning 403?
Or:
Why is my API returning 403?
The better question is:
Which component returned 403?
Once I know that, the rest of the problem usually becomes much smaller.
Maybe it's an application permission.
Maybe nginx has a deny rule.
Maybe the CDN doesn't like the request.
Maybe a WAF rule matched something in the body.
Maybe an IP restriction rejected the client before the application had any idea a request existed.
The browser can't tell me that.
It only sees the final response.
And that's probably the useful mental model I'll keep from this one:
HTTP tells me what happened to the request. It doesn't necessarily tell me where it happened.