WordPress 403 Forbidden: Diagnose and Fix Permissions, WAF Rules, and Blocking

Was this helpful?

403 Forbidden is the most unhelpful kind of “no.” It’s your stack telling you, with a straight face, that it understood the request and refused it anyway—often without explaining which component did the refusing.

In WordPress land, that refusal can come from anywhere: filesystem permissions, a web server rule you forgot you added, a WAF that thinks your POST body looks like SQLi, a CDN protecting the origin “for your own good,” or a security plugin that woke up and chose violence. This guide is how you pin the blame on the correct layer and fix it without turning your site into an open bar.

What a 403 actually means (and why it’s not one thing)

HTTP 403 means the server (or something acting like one) refuses to authorize the request. The important part: “the server” might be a CDN edge, a reverse proxy, your origin web server, a WAF module, or even WordPress itself via a plugin returning a 403.

Common patterns:

  • 403 at the edge: CDN/WAF blocks before traffic reaches your origin. Your origin logs look clean because the request never arrived.
  • 403 at the proxy/web server: Nginx/Apache rules, access controls, path denies, directory listing controls, auth misconfig, or TLS client cert rules.
  • 403 from filesystem: web server user can’t read the file or traverse directories. Nginx often logs “permission denied” and returns 403/404 depending on config.
  • 403 from app logic: WordPress core rarely does this on its own for public pages, but security plugins and custom mu-plugins absolutely do.

Operationally, 403 is less an error code and more a negotiation breakdown between “who you are” and “what you’re allowed to touch.” Fixing it is about proving which actor vetoed the request, then adjusting the narrowest permission or rule needed.

One quote worth keeping in your pocket when triaging access failures: paraphrased idea from Werner Vogels: “Everything fails all the time; design and operate as if failure is normal.” That mindset applies to permissions and WAF rules too—expect accidental denial and build quick ways to locate it.

Joke #1: A 403 is like a nightclub bouncer who won’t tell you the dress code—just that your “request is not welcome.”

Fast diagnosis playbook (first/second/third)

This is the shortest path to “who blocked it” without wandering through every config file on the planet.

First: determine where the 403 is generated

  1. Check response headers from a client: look for CDN/WAF markers and server signatures. If you see a CDN header or “Server: cloudflare” style behavior, assume edge block until proven otherwise.
  2. Check whether the request hits the origin: tail origin access logs while reproducing. No log entry often means edge block or wrong DNS/path.
  3. Compare from two networks: your office IP might be blocked while the world works fine (or vice versa).

Second: classify the blocked surface

  1. Is it only wp-admin/wp-login.php? Think WAF rules, brute-force protection, geo/IP allowlists, or auth gating.
  2. Is it only uploads/static files? Think filesystem permissions, Nginx location blocks, hotlink protection, or wrong MIME/deny by extension.
  3. Is it only POST requests? Think ModSecurity/OWASP CRS, request body size limits, or security plugins flagging payloads/nonces.
  4. Is it intermittent? Think rate limiting, fail2ban, bot management, or a proxy layer with inconsistent config.

Third: fix the narrowest layer possible

  1. If it’s edge/WAF: start with rule logs and “bypass for this URI” testing before you change origin permissions.
  2. If it’s web server: fix specific location/<Directory> rules or auth scopes—don’t carpet-bomb with “allow all.”
  3. If it’s filesystem: fix ownership, mode bits, and ACLs on the minimal subtree.
  4. If it’s WordPress/plugin: disable the offending plugin safely, or add an allow rule for the specific route.

Facts and history: why 403 keeps happening

Some context helps because 403 isn’t just a WordPress thing—it’s a side effect of how the web matured.

  • Fact 1: The “403 Forbidden” status code is defined by HTTP standards going back to early RFCs; it’s meant for understood requests where authorization is refused, not malformed requests (that’s 400).
  • Fact 2: Apache’s .htaccess model popularized per-directory overrides; it also popularized self-inflicted 403s when a rewrite or deny directive lands in the wrong folder.
  • Fact 3: Nginx’s configuration style (location precedence, regex locations, internal redirects) makes it fast and predictable—until one “deny all;” in the wrong block becomes a stealth bouncer.
  • Fact 4: ModSecurity emerged to protect web apps from common attacks without rewriting the app; the OWASP Core Rule Set is great, but false positives are a recurring tax you must budget for.
  • Fact 5: WordPress’s XML-RPC interface existed for remote publishing clients; it became a brute-force and amplification favorite, leading many WAF templates to block or throttle it aggressively.
  • Fact 6: The REST API endpoints (/wp-json/) are now heavily used by modern WP features and headless setups; blocking them breaks things in ways that look like “random admin 403.”
  • Fact 7: “Hotlink protection” (referer-based deny rules) was a common bandwidth defense in the early 2000s; it still causes 403 on images when sites move behind CDNs or change domains.
  • Fact 8: Many hosting panels historically recommended 777 permissions as a “fix.” That advice is an heirloom from insecure shared hosting eras and should stay in the attic.
  • Fact 9: Fail2ban-style IP bans started as pragmatic SSH protection and then expanded to HTTP auth logs; it’s effective, but it can also ban your own monitoring and office NAT.

Layered debugging: identify who said “forbidden”

If you treat a 403 as “WordPress is broken,” you’ll change the wrong thing and ship more risk. The trick is to walk the request path:

  1. Client (browser, bot, API client)
  2. DNS (are you even hitting the right edge/origin?)
  3. CDN/WAF (edge policies, bot management, geo/IP allowlist)
  4. Load balancer / reverse proxy (ACLs, path rules, auth)
  5. Web server (Nginx/Apache access rules, rewrite, auth, internal locations)
  6. OS + filesystem (ownership, mode bits, ACLs, SELinux/AppArmor)
  7. PHP-FPM + WordPress (plugin blocks, application-level auth, nonce/CSRF)
  8. Downstream dependencies (object storage for uploads, SSO, third-party auth)

Most 403s become obvious once you identify the layer. So the first job is attribution.

Practical tasks: commands, outputs, and decisions (12+)

These are the real moves. Each task includes: a command, what the output means, and what decision you make next. Use them in order until you’ve pinned the culprit.

Task 1: Reproduce and capture headers (detect edge vs origin)

cr0x@server:~$ curl -I https://example.com/wp-login.php
HTTP/2 403
date: Fri, 26 Dec 2025 10:12:33 GMT
content-type: text/html; charset=UTF-8
server: cloudflare
cf-ray: 85a1b2c3d4e5f678-LHR

What it means: Response is served by Cloudflare (or at least the edge identifies itself). The 403 is likely edge-generated.

Decision: Don’t touch Nginx permissions yet. Go to WAF/CDN logs and rules first; also confirm whether the request reaches the origin (Task 2).

Task 2: Confirm whether the origin sees the request

cr0x@server:~$ sudo tail -f /var/log/nginx/access.log
192.0.2.10 - - [26/Dec/2025:10:12:33 +0000] "GET / HTTP/1.1" 200 15432 "-" "curl/8.5.0"

What it means: You see / requests but not /wp-login.php (while the client gets 403). That’s consistent with edge blocking only that path.

Decision: Focus on edge/WAF path policies. If you do see the blocked request in access logs, jump to Tasks 4–9.

Task 3: Bypass the CDN (hit origin directly) to isolate layers

cr0x@server:~$ curl -I --resolve example.com:443:203.0.113.20 https://example.com/wp-login.php
HTTP/2 200
date: Fri, 26 Dec 2025 10:13:10 GMT
content-type: text/html; charset=UTF-8
server: nginx

What it means: The origin returns 200, but the public path returns 403. That’s an edge/WAF policy issue, not a WordPress permissions issue.

Decision: Fix WAF rules or bot protections; avoid “chmod everything” panic.

Task 4: Inspect Nginx error log for permission/access clues

cr0x@server:~$ sudo tail -n 50 /var/log/nginx/error.log
2025/12/26 10:14:22 [error] 1123#1123: *884 open() "/var/www/example.com/wp-admin/admin.php" failed (13: Permission denied), client: 192.0.2.10, server: example.com, request: "GET /wp-admin/ HTTP/1.1", host: "example.com"

What it means: Classic filesystem permission denial (errno 13). Nginx couldn’t read the target file.

Decision: Check ownership/mode bits/ACLs/SELinux. Start with Tasks 6–8 and Task 10.

Task 5: Verify the vhost config doesn’t explicitly deny paths

cr0x@server:~$ sudo nginx -T 2>/dev/null | sed -n '/server_name example.com/,$p' | sed -n '1,160p'
server {
    server_name example.com;
    root /var/www/example.com;
    location ~* \.(php|phtml)$ { include snippets/fastcgi-php.conf; }
    location ~* /wp-admin/ { deny all; }
}

What it means: Someone added a deny all for /wp-admin/. Nginx is doing exactly what it was told.

Decision: Remove or narrow the deny rule (e.g., allow admin IPs), then reload Nginx (Task 9). Don’t touch filesystem permissions; they’re probably fine.

Task 6: Check file and directory permissions on the failing path

cr0x@server:~$ namei -l /var/www/example.com/wp-admin/admin.php
f: /var/www/example.com/wp-admin/admin.php
drwxr-x--- root root /
drwxr-x--- root root var
drwxr-x--- root root www
drwxr-x--- root root example.com
drwxr-x--- root root wp-admin
-rw-r----- root root admin.php

What it means: The entire path is owned by root with group root and restrictive execute permissions on directories (750). If Nginx runs as www-data, it can’t traverse.

Decision: Fix ownership to the web user/group (Task 8) or adjust group membership and permissions safely. Avoid world-writable nonsense.

Task 7: Confirm the web server user

cr0x@server:~$ ps -o user,group,cmd -C nginx | head -n 5
USER     GROUP    CMD
root     root     nginx: master process /usr/sbin/nginx
www-data www-data nginx: worker process
www-data www-data nginx: worker process

What it means: Workers run as www-data. That’s the identity that must read and traverse the WordPress tree.

Decision: Ensure directories are at least executable by www-data (or its group) and files readable, then retest.

Task 8: Fix ownership and baseline permissions (safe defaults)

cr0x@server:~$ sudo chown -R www-data:www-data /var/www/example.com
cr0x@server:~$ sudo find /var/www/example.com -type d -exec chmod 755 {} \;
cr0x@server:~$ sudo find /var/www/example.com -type f -exec chmod 644 {} \;

What it means: Directories become traversable, files readable. WordPress can still write where it must (you may later tighten with specific writable dirs like wp-content/uploads).

Decision: Retest. If 403 persists with “permission denied” gone, move to web server rules or SELinux/AppArmor.

Task 9: Validate and reload Nginx/Apache after config changes

cr0x@server:~$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
cr0x@server:~$ sudo systemctl reload nginx

What it means: Config is valid and reloaded without dropping connections (reload is friendlier than restart).

Decision: Retest the exact failing URL. If you still get 403, inspect error logs again; don’t assume the same root cause.

Task 10: Check SELinux status and audit denials (Linux-specific “invisible wall”)

cr0x@server:~$ getenforce
Enforcing
cr0x@server:~$ sudo ausearch -m avc -ts recent | tail -n 5
type=AVC msg=audit(1766744162.112:421): avc:  denied  { read } for  pid=2314 comm="nginx" name="wp-config.php" dev="xvda1" ino=393231 scontext=system_u:system_r:httpd_t:s0 tcontext=unconfined_u:object_r:default_t:s0 tclass=file permissive=0

What it means: SELinux denies Nginx from reading files labeled default_t. This often returns 403/500-ish behavior depending on the stack.

Decision: Fix contexts (Task 11). Don’t disable SELinux in production just because it’s yelling.

Task 11: Restore correct SELinux context for the web root

cr0x@server:~$ sudo semanage fcontext -a -t httpd_sys_content_t "/var/www/example.com(/.*)?"
cr0x@server:~$ sudo restorecon -Rv /var/www/example.com
restorecon reset /var/www/example.com context unconfined_u:object_r:default_t:s0->unconfined_u:object_r:httpd_sys_content_t:s0

What it means: Files now have labels that allow the httpd domain to read them.

Decision: Retest. If WordPress needs to write to uploads, set the write context only on that directory (not the whole tree).

Task 12: Detect ModSecurity blocks (403 with “Access denied” vibes)

cr0x@server:~$ sudo tail -n 30 /var/log/apache2/modsec_audit.log
--d8a3c0f4-H--
Message: Access denied with code 403 (phase 2). Operator GE matched 5 at TX:anomaly_score.
[id "949110"] [msg "Inbound Anomaly Score Exceeded"] [tag "OWASP_CRS"]
Action: Intercepted (phase 2)

What it means: ModSecurity/OWASP CRS blocked the request. Often triggered by certain plugin POST payloads, REST API calls, or weird query strings.

Decision: Whitelist narrowly: specific rule ID for a specific path, or tune anomaly thresholds for that vhost. Don’t globally disable WAF unless you enjoy incident response.

Task 13: Check fail2ban bans for your IP (the “why only me?” scenario)

cr0x@server:~$ sudo fail2ban-client status
Status
|- Number of jail:	3
`- Jail list:	sshd, nginx-http-auth, wordpress-login
cr0x@server:~$ sudo fail2ban-client status wordpress-login
Status for the jail: wordpress-login
|- Filter
|  |- Currently failed:	1
|  `- Total failed:	37
`- Actions
   |- Currently banned:	1
   `- Banned IP list:	192.0.2.10

What it means: Your IP is banned at the host level. You’ll see 403/444/connection drops depending on how the jail is configured.

Decision: Unban and adjust thresholds or allowlist your office/VPN egress (Task 14). Also check whether your monitoring is generating false login attempts.

Task 14: Unban an IP and add an allowlist (carefully)

cr0x@server:~$ sudo fail2ban-client set wordpress-login unbanip 192.0.2.10
1
cr0x@server:~$ sudo grep -R "ignoreip" -n /etc/fail2ban/jail*.conf
/etc/fail2ban/jail.local:3:ignoreip = 127.0.0.1/8 203.0.113.0/24

What it means: IP is unbanned; allowlist exists in jail.local. The “1” indicates success.

Decision: Add only stable trusted IP ranges. If your office IP changes daily, use a VPN with known egress instead of sprinkling random addresses everywhere.

Task 15: Check WordPress is returning the 403 (application-layer)

cr0x@server:~$ curl -s -o /dev/null -w "%{http_code}\n" https://example.com/wp-json/wp/v2/users
403

What it means: The REST endpoint returns 403. That might be expected (unauthenticated users listing blocked), or it might be a plugin blocking all REST calls.

Decision: Compare with a known-good endpoint like /wp-json/ index and test with authentication if needed. If admin UI is also failing, inspect security plugins and mu-plugins.

Task 16: Temporarily disable plugins safely (without wp-admin)

cr0x@server:~$ cd /var/www/example.com
cr0x@server:~$ sudo mv wp-content/plugins wp-content/plugins.disabled
cr0x@server:~$ sudo mkdir wp-content/plugins

What it means: WordPress sees “no plugins” and falls back to core behavior. This is the cleanest A/B test when wp-admin is inaccessible.

Decision: If 403 disappears, restore plugins and bisect: re-enable in batches until the offender is found. If 403 persists, plugins aren’t your cause.

Task 17: Check for .htaccess denies (Apache or Nginx+htaccess via panels)

cr0x@server:~$ sudo grep -nE "deny from|require all denied|RewriteRule" /var/www/example.com/.htaccess
12:Require all denied

What it means: A hard deny exists. Sometimes left behind by a “maintenance lockdown” or migration tool.

Decision: Remove or scope it (e.g., protect only a staging directory). Then reload Apache and retest.

Task 18: Validate Apache authorization config and the effective vhost

cr0x@server:~$ sudo apachectl -S | sed -n '1,80p'
VirtualHost configuration:
*:80                   example.com (/etc/apache2/sites-enabled/example.conf:1)
ServerRoot: "/etc/apache2"
Main DocumentRoot: "/var/www/html"

What it means: Confirms which vhost file is serving the domain. 403 issues often come from editing the wrong file or the wrong vhost.

Decision: Open the referenced config and look for <Directory> blocks with Require all denied or missing Require all granted.

WordPress-specific 403 failure modes

WordPress is a permission magnet because it spans public pages, admin areas, REST APIs, upload handling, and lots of third-party code. Here are the usual suspects—and how they manifest.

wp-admin returns 403, front page works

This is often deliberate blocking by:

  • WAF rule template that blocks /wp-admin from non-allowlisted IPs
  • Nginx/Apache “secure admin” block added during an incident and never removed
  • fail2ban jail triggered by repeated login attempts
  • Security plugin (Wordfence, iThemes, etc.) in “lockdown” mode

What to do: Identify whether the origin sees the request. If it does, search configs for wp-admin blocks and check WAF/fail2ban. Don’t chase filesystem permissions unless logs show (13: Permission denied).

wp-login.php returns 403 or loops

Typical causes:

  • Bot protection thinks your login is a bot; blocks based on JS challenge/cookies
  • WAF sees credential stuffing patterns; rate limits or denies
  • POST body inspection flags password strings as “attack payload” (false positives happen)
  • Misconfigured reverse proxy headers causing auth/cookie issues, which can look like 403 after redirects

What to do: Try from an alternate IP and with a clean browser profile. Check WAF logs for the exact request ID and rule ID. If a security plugin is involved, disable it via filesystem as in Task 16.

Uploads (wp-content/uploads) return 403

This one is usually boring. Boring is good.

  • Directory permissions prevent traversal or reading
  • Nginx location blocks deny “hidden” files but your rules are too broad
  • Hotlink protection rejects missing/changed Referer header
  • Uploads moved to object storage and origin is denying direct access

What to do: Check an individual file with curl headers and verify it’s a static file served by the web server, not PHP. Then inspect filesystem perms and Nginx location precedence. If you use offload plugins, check signed URL logic and bucket policies.

wp-json or admin-ajax returns 403

This breaks editors, theme customizers, some caching plugins, and headless front ends. Typical culprits:

  • WAF rule blocks /wp-json because it’s a “API endpoint”
  • Security plugin blocks REST API for unauthenticated users (sometimes too aggressively)
  • Mixed content or proxy header issues causing nonce verification failures that can manifest as 403 in some setups

What to do: Test /wp-json/ index endpoint first; compare responses with and without authentication; then decide whether the block is intended policy or accidental breakage.

WAF/CDN/security tooling: how 403s get manufactured

WAFs are necessary. They are also confident. Your job is to keep them confident and correct.

Edge 403 vs origin 403: why it matters

An edge-generated 403 will:

  • Not appear in origin access logs
  • Often include edge-specific headers and ray/request IDs
  • Sometimes present a branded block page

An origin-generated 403 will:

  • Show up in Nginx/Apache access logs with status 403
  • Usually have correlated error log context (“permission denied”, “access forbidden by rule”, auth failures)
  • Be reproducible by bypassing the edge (Task 3)

ModSecurity/CRS false positives: treat it like a debugging problem, not a religion war

False positives typically spike when you:

  • Roll out a new plugin that posts JSON or large forms
  • Enable a page builder that sends complex serialized payloads
  • Add headless endpoints or custom REST routes

Fix strategy:

  1. Identify the rule ID blocking (Task 12).
  2. Confirm the request is legitimate (don’t whitelist an actual attack).
  3. Disable or adjust the specific rule for the specific URI or parameter.
  4. Keep an audit trail. “We disabled WAF” is not a policy; it’s a confession.

Rate limits and bot management

Rate limiting often returns 403 (or 429, depending on vendor). What makes it tricky is that it may be triggered by:

  • Real attackers (good)
  • Your own uptime monitors (embarrassing)
  • Load tests (predictable)
  • Mobile carrier NATs (one IP represents thousands of users)

Most vendors allow a “challenge” mode rather than a hard block. Prefer challenge for anonymous traffic; use hard block for known-bad paths like abusive XML-RPC patterns.

Security plugins and “helpful” lockouts

WordPress security plugins can return 403 from inside PHP before Nginx/Apache can help you. Their logs are sometimes in wp-admin (which you can’t access), which is… a design choice.

Joke #2: The only thing more persistent than a brute-force bot is a security plugin that locked you out five minutes before your demo.

Operational approach:

  • Keep plugin configs in version control where possible (or at least documented).
  • Know the filesystem “kill switch” (Task 16).
  • Prefer infrastructure-level controls (WAF, rate limit) for generic threats and keep plugins for WP-specific hardening.

Common mistakes: symptom → root cause → fix

These are the ones that burn hours because the symptom looks like something else.

1) Only one office/VPN gets 403; everyone else is fine

Symptom: Marketing says “site is down,” but your phone on LTE works.

Root cause: IP ban (fail2ban, WAF reputation, bot score) against your office NAT/VPN egress.

Fix: Check bans (Task 13), unban (Task 14), then create a stable allowlist strategy. If you must allowlist humans, use a VPN with predictable egress.

2) wp-admin 403 after “hardening” change

Symptom: Front page 200. Admin pages 403.

Root cause: An Nginx location or Apache <Directory> deny rule applied too broadly (Task 5, Task 18), or a WAF path rule.

Fix: Remove the deny or scope it to the correct IP range and method. Validate precedence in Nginx (regex vs prefix). Reload and retest.

3) Static files 403 after migration or restore

Symptom: Images/CSS return 403; PHP pages might still render.

Root cause: Ownership reset to root during restore; directories not traversable by web user (Task 6–8).

Fix: Correct ownership and permissions; consider using tar with preserved ownership appropriately and verify the runtime user.

4) Random plugin actions 403, especially on POST

Symptom: Form saves, editor updates, or API calls fail with 403; GET works.

Root cause: WAF/ModSecurity rule triggered by POST body content or size (Task 12), or request body limits.

Fix: Find the rule ID and tune/whitelist narrowly. If it’s size-related, adjust request body size settings in Nginx/Apache and PHP, but keep reasonable limits.

5) 403 for directories that should be public

Symptom: Visiting /wp-content/ or other directories returns 403.

Root cause: Directory listing disabled is returning 403 (expected) and you’re testing the directory, not a file; or an index file missing.

Fix: Request an actual file (/wp-content/uploads/...). Ensure index files exist where needed. Don’t enable directory listing on production unless you like surprises.

6) 403 appears after enabling SELinux

Symptom: Permissions look correct, but still 403 with “permission denied” in logs or silent blocks.

Root cause: SELinux contexts wrong (Task 10).

Fix: Restore correct contexts (Task 11). Keep SELinux; fix labeling rather than removing guardrails.

Checklists / step-by-step plan

Checklist A: Triage in 10 minutes

  1. Grab headers with curl -I and note server/WAF markers (Task 1).
  2. Tail origin access logs while reproducing (Task 2).
  3. Bypass CDN with --resolve if applicable (Task 3).
  4. If origin sees it: inspect error logs for “permission denied” or “access forbidden” (Task 4).
  5. Search web server config for deny rules on the path (Task 5, Task 18).
  6. Check filesystem traversal with namei -l (Task 6).
  7. If still weird: check SELinux (Task 10) and WAF/ModSecurity logs (Task 12).

Checklist B: Hardening without self-sabotage

  1. Lock down wp-admin by IP only if you have stable IPs; otherwise use SSO/VPN rather than brittle allowlists.
  2. Throttle login and XML-RPC at the edge. Prefer rate limits to blanket denies unless you truly don’t need the feature.
  3. Document every deny rule with a comment and a ticket reference in config. Future-you is a stranger; treat them kindly.
  4. Keep a break-glass method: ability to bypass CDN to origin, and ability to disable plugins via filesystem.
  5. Monitor WAF false positives: count blocks by rule ID and URI so you can tune with evidence.

Checklist C: Post-fix validation

  1. Re-test the exact failing URL, method, and headers (GET vs POST matters).
  2. Verify logs now show the request with 200/302 as expected.
  3. Confirm no broad exposure was introduced (no new public access to wp-config.php, no directory listing).
  4. Test from multiple networks (office, LTE, external) to catch IP-based policies.
  5. Add a synthetic check for the endpoint that broke (wp-login, wp-json, uploads file).

Three corporate mini-stories from the trenches

Mini-story 1: The incident caused by a wrong assumption

The company had a WordPress marketing site behind a CDN/WAF. One morning, editors reported that /wp-admin/ returned 403. Engineering did what engineering does under pressure: assumed “permissions” and began changing ownership and mode bits on the WordPress tree.

It didn’t help. In fact, it made it worse: a rushed chown -R changed ownership of a directory that a deployment job expected to own, and now deploys started failing. Two teams, one 403, and a growing sense that the internet was personally insulting them.

The real issue was visible in the first curl header capture: the 403 was served by the edge, and the origin never saw the request. A new WAF managed rule had started blocking /wp-admin/ from “untrusted” countries, and the editors were on a corporate VPN that egressed elsewhere. From the WAF’s perspective, the admin traffic had teleported.

Once someone bypassed the CDN with a direct origin resolve, wp-admin was fine. The fix was a narrow WAF exception for authenticated admin endpoints from the VPN egress range, plus a policy change: no VPN egress changes without telling the people operating admin access.

Lesson: if you don’t know which layer produced the 403, you’re not troubleshooting yet—you’re guessing with root privileges.

Mini-story 2: The optimization that backfired

A different team wanted faster performance and fewer origin hits. They enabled aggressive caching and bot protection at the edge, including rules to “block suspicious POST requests” and “challenge unknown user agents.” The front page flew. Lighthouse scores improved. Everyone high-fived.

Then the site’s form plugin started failing. Submissions intermittently returned 403. It only happened for some users, and it didn’t reproduce reliably in the office. Classic.

The backfire was subtle: the edge considered certain mobile browser combinations as “low reputation” and applied extra scrutiny. The form payload included a blob of serialized data that looked like an attack signature to the WAF. Some requests were challenged; others were blocked.

The team initially tried to “optimize” again by adding more caching rules, which only increased variance. Finally they pulled the WAF audit events by rule ID and correlated them with the failing endpoint. They added a scoped exception: allow POST to that form endpoint with normal inspection but without the aggressive anomaly threshold. Conversion rates recovered.

Lesson: performance optimizations that change request handling (especially POST and auth flows) are reliability changes. Treat them like releases with monitoring and rollback, not like a checkbox.

Mini-story 3: The boring but correct practice that saved the day

A large enterprise ran multiple WordPress instances—some legacy, some modern, all behind a reverse proxy farm. They had a very unsexy habit: every time a 403 happened, the on-call engineer captured three artifacts and attached them to the ticket: the curl -I headers, the origin access log line (or proof it was absent), and the relevant error log excerpt.

One weekend, a 403 wave hit across several sites, mostly on /wp-json/ and /wp-admin/admin-ajax.php. The immediate suspicion was “a WordPress update broke something.” But their three artifacts told a different story: the origin saw almost nothing, and the 403 pages carried the same edge identifiers.

Because they had consistent evidence, they escalated to the network/security team with specifics: timestamps, request IDs, and affected paths. Security found a newly rolled-out managed rule set that was overly aggressive for REST-like endpoints. They tuned it with a targeted exception rather than ripping the WAF out.

Within an hour, the incident was contained. There was no frantic permission changing, no plugin roulette, and no “just disable security” midnight heroics.

Lesson: boring evidence collection is a force multiplier. It keeps you from fighting the wrong layer, and it gives other teams what they need to help quickly.

FAQ

Why does WordPress show 403 only on wp-admin?

Because wp-admin is a common hardening target. CDN/WAF templates, server config snippets, and security plugins often gate it by IP, country, bot score, or rate limit. Confirm whether the origin sees the request; then check explicit deny rules and ban systems.

Is a 403 always a permissions problem on disk?

No. Filesystem permission denial is just one common cause. Edge policies, Nginx/Apache auth rules, ModSecurity, and plugins can all return 403. Look at headers and logs to attribute the layer first.

What file permissions should WordPress have?

A common baseline is directories 755 and files 644, owned by the user/group your web server runs as (or a deployment user with group access). Writable directories are typically wp-content/uploads and possibly cache directories. Avoid 777.

Why do I see 403 when I request a directory like /wp-content/?

Because directory listing is typically disabled. If there’s no index file, the server may return 403 to avoid listing contents. Test an actual file path instead of the directory.

How can I tell if Cloudflare (or another CDN) is blocking me?

Check headers (Task 1), then tail origin logs (Task 2). If the origin never sees the request and the headers show edge identifiers, it’s an edge block. Confirm by bypassing the CDN with --resolve (Task 3).

Why does the site work in my browser but curl gets 403?

Bot management and WAF rules often treat unknown user agents as suspicious. Try curl -I -A "Mozilla/5.0" to compare, but don’t “fix” by allowing everything—fix by tuning the bot/WAF policy appropriately.

Can a WordPress plugin return a 403 even if Nginx/Apache is fine?

Yes. Security plugins and custom code can reject requests based on IP, headers, paths, or perceived threats. Disable plugins via filesystem to A/B test (Task 16). If that fixes it, re-enable in batches and find the offender.

Why do POST requests fail with 403 but GET requests work?

POST bodies are inspected by WAFs and can trigger rules; they’re also subject to size and content restrictions. Check ModSecurity audit logs (Task 12) and web server request size limits. Tune narrowly, and validate you’re not masking a real attack.

Should I disable the WAF to “prove” it’s the problem?

Prefer bypass testing (origin direct resolve) or a scoped rule bypass for a single path/IP first. Full disable is a last resort, short-lived, and ideally done during controlled investigation with monitoring. Otherwise you’ll “fix” 403 by inviting worse problems.

Conclusion: next steps that stick

403s are a coordination problem masquerading as a web error. The fix is rarely heroic; it’s usually precise.

  1. Attribute the 403 to a layer using headers, origin logs, and bypass testing.
  2. Use evidence: error logs for permission denials, WAF audit logs for rule IDs, ban lists for IP blocks.
  3. Apply the narrowest fix: adjust one rule, one path, one context label, one directory subtree—then retest.
  4. Write down what changed and add a synthetic check for the endpoint that broke, so the next 403 is caught early and diagnosed fast.
← Previous
Pure HTML/CSS Landing Page: Hero, Features, Pricing, FAQ (Docs-Style)
Next →
WordPress “Allowed memory size exhausted”: fix it for good

Leave a comment