A 403 Forbidden on a WordPress site means something between the visitor and PHP decided to refuse the request. Before you reset permissions or delete .htaccess, find out which layer it was: the response body and one line in the server error log usually name it outright.
That step is what the popular guides skip. The three top-ranking ones we read on 2026-11-05 (WPBeginner, WPShout and DreamHost) each list seven to twelve fixes to try in order.
None of them shows how to tell a Cloudflare block from an Apache rule from a WordPress nonce failure. Those are three different problems with three different fixes, and trying all twelve in order is how people end up with a 777 uploads folder.
What does 403 Forbidden actually mean?
The server understood the request and refused to serve it. It is not "not found" (404) and not "crashed" (500).
It is also not a login problem in the HTTP sense. A 401 asks you to authenticate, while a 403 says the answer is no regardless.
On a WordPress site, five layers can say no, from the outside in:
- The CDN or proxy (Cloudflare, Sucuri, a host's edge).
- A server firewall, usually ModSecurity, running inside Apache or nginx.
- The web server's own rules:
Require all denied,deny all, filesystem permissions, a directory with no index file. - A security plugin, either through the
.htaccessrules it wrote or from inside PHP. - WordPress core, which sends 403 for failed nonces and missing capabilities.
Layers 1 to 3 refuse before WordPress loads, so deactivating plugins does nothing about them. Layers 4 and 5 run inside WordPress, so file permissions do nothing about them.
How do I tell which layer sent the 403?
Request the failing URL with curl and read the headers and the first few lines of the body:
curl -sS -D - -o /tmp/403.html https://example.com/wp-admin/ && head -c 600 /tmp/403.html
Then match what you see:
| What you see | Who sent it |
|---|---|
server: cloudflare and a cf-ray header, page says "Sorry, you have been blocked" |
Cloudflare's WAF or a firewall rule |
<p>You don't have permission to access this resource. |
Apache's built-in 403 page |
<center><h1>403 Forbidden</h1></center> with nginx underneath |
nginx's built-in 403 page |
| A plain-styled WordPress page: "Sorry, you are not allowed to access this page." | WordPress core, a capability check |
| A WordPress page: "The link you followed has expired." | WordPress core, a failed nonce |
Body is just -1 from admin-ajax.php |
WordPress core, a failed AJAX nonce |
JSON with "code":"rest_cookie_invalid_nonce" |
WordPress core, REST API cookie check |
| A branded page from your security plugin | The plugin, from inside PHP |
The Apache and nginx strings are the servers' compiled-in defaults: Apache's lives in modules/http/http_protocol.c and nginx's in ngx_http_special_response.c. A host can replace them with a custom error page, which is itself a clue that the refusal came from the server and not from WordPress.
Run the same curl from a different network too. A 403 that only you get is almost always an IP block at the CDN, the firewall or a security plugin's lockout list.
What does the server error log say?
If the body pointed at Apache, the error log names the exact rule. These are the four messages that cover nearly every WordPress 403, quoted from the Apache 2.4 source:
| Log line | What it means | Fix |
|---|---|---|
AH01630: client denied by server configuration: /var/www/html/wp-content/... |
A Require all denied (or old Deny from all) rule matched, usually in an .htaccess file |
Find the rule, see below |
AH00035: access to /... denied ... because search permissions are missing on a component of the path |
A directory on the path is missing the execute bit for the server user | chmod 755 on the directory |
AH00132: file permissions deny server access: /var/www/html/... |
The file itself is not readable by the server user | chmod 644 on the file, check ownership |
AH01276: Cannot serve directory ...: No matching DirectoryIndex (...) found, and server-generated directory index forbidden by Options directive |
You requested a folder with no index.php and listings are off |
Usually correct behaviour; request a real file |
On nginx the equivalent permission failure reads "/var/www/html/index.php" is forbidden (13: Permission denied). ModSecurity logs its own line, starting ModSecurity: Access denied with code 403, followed by the rule ID that matched.
That rule ID is what your host needs if you ask them to whitelist something. "My site gives a 403" gets a canned reply; "rule 949110 blocks POSTs to /wp-admin/post.php" gets a fix.
Why does wp-admin give a 403 when the front end works?
Three causes, in rough order of how often we see them.
A security rule protecting the login. Many security plugins and host firewalls restrict wp-login.php and wp-admin/ by IP, country or rate. If the 403 body is a Cloudflare or plugin page, that is the one.
A capability check. "Sorry, you are not allowed to access this page." is WordPress refusing a logged-in user who lacks the capability for that screen. Core sends it with status 403 from wp-admin/includes/menu.php.
We wrote a whole post on that exact message, because the fix is in the user's roles, not the server.
A firewall rule matching what you saved. ModSecurity rules look at request bodies, so a post containing <script>, a SQL fragment in a code sample, or a long base64 string can trip one on save. The symptom is a 403 on post.php or the REST API only when that content is in the request.
Why do admin-ajax.php and the REST API return 403?
Usually because a nonce failed, not because anything is broken on the server.
check_ajax_referer() in wp-includes/pluggable.php calls wp_die( -1, 403 ) when the nonce does not verify. That -1 body is the giveaway.
The REST API does the same for logged-in requests: rest_cookie_check_errors() in wp-includes/rest-api.php returns rest_cookie_invalid_nonce, "Cookie check failed", with status 403. In the block editor that surfaces as a save that fails, which is related to the "not a valid JSON response" error.
The common causes are a page cached with a stale nonce in it, a login that expired in another tab, or a plugin that never sent the nonce. Our post on WordPress nonces covers their lifetime and why a full-page cache breaks them.
Why do I get a 403 after migrating a WordPress site?
Because the files brought their rules with them, and the new server takes them literally.
We hit this ourselves on 2026-10-30. A site imported from SiteGround had SiteGround Security's "Directory Hardening" block in wp-content/.htaccess:
<FilesMatch "\.(?i:php)$">
Require all denied
</FilesMatch>
Every PHP file under wp-content/ returned 403, and Apache logged AH01630: client denied by server configuration.
On SiteGround that was intended, and the site worked. On the new server it also blocked tooling that legitimately runs PHP from wp-content/.
Other security plugins offer the same kind of "disable PHP in uploads" or "protect wp-content" switch. When the plugin is deactivated or the site is moved, the rules usually stay behind in the .htaccess files.
So after any migration, list every .htaccess, not just the root one:
find /var/www/html -name .htaccess -print -exec grep -nE 'Require all denied|Deny from all|FilesMatch' {} \;
The other migration classic is ownership. Files extracted by root or by an FTP user the web server cannot read produce AH00132 or AH00035, and the fix is chown to the web server user, not wider permissions.
What file permissions should WordPress have?
755 for directories and 644 for files, owned by the account PHP runs as. Many hosts also suggest 440 or 400 for wp-config.php.
The reason directories need the execute bit is literally the AH00035 message above. Without x, the server cannot traverse into the folder, even if every file inside is readable.
Never use 777 to make a 403 go away. If 777 "fixes" it, the real problem was ownership, and you have now made every file writable by every process on the server.
Should I just delete .htaccess?
Rename it, do not delete it, and only after the log has pointed at it.
WordPress regenerates only its own block between # BEGIN WordPress and # END WordPress when you save Settings, Permalinks. Anything else in the file, such as redirects, cache rules or the security block that caused this, is gone for good if you delete it.
Our .htaccess post shows exactly what core writes and when it silently refuses to.
How do I narrow it down without touching the live site?
Copy the site and see whether the 403 comes with it. That one test splits the five layers in two.
The SandyWP Cloner copies the database and wp-content into a sandbox on a different server, without your host's CDN rules, firewall or IP lockouts.
- The 403 follows the copy: the cause is in the files or the database. That means a
wp-content/.htaccessrule, a plugin, or a capability problem. - The 403 stays behind: the cause is the host's layer (CDN, ModSecurity, server config). Check the live root
.htaccessas well, since it sits outsidewp-content.
On the copy you can bisect freely. Open a shell with SSH access and work through it:
find wp-content -name .htaccess
mv wp-content/.htaccess wp-content/.htaccess.off
wp plugin deactivate --all
wp plugin activate wordfence
Restore one piece at a time until the 403 comes back. The last piece you restored is the cause, and you can fix it on live in one change instead of twelve.
The honest limit: a sandbox cannot reproduce your host's firewall or CDN rules. If the copy is clean, the remaining work is reading the live server's error log or sending your host the rule ID.
The short version
curlthe failing URL and read the body: it tells you whether the CDN, the server, a plugin or WordPress refused.- If it was the server, the error log line (
AH01630,AH00035,AH00132,AH01276) names the rule or the file. - If it was WordPress, it is a nonce or a capability, and permissions are irrelevant.
- After a migration, check every
.htaccessunderwp-content/, not only the root. - Never
777, never delete.htaccesswithout a copy.
