You click Update in the editor, submit a contact form, save a plugin setting or open a page, and instead of the result you get 403 Forbidden, “You don’t have permission to access this resource”, “Access denied”, “Sorry, you have been blocked” or a branded block page. In the block editor it may only show as “Updating failed” with a 403 in the Network tab.
A 403 is not a crash. Something received your request, understood it, and refused it on purpose. The job is to find which layer refused it: a CDN firewall, a server web application firewall (WAF) such as ModSecurity, the web server itself, a security plugin, or a password protection rule. Each sends a recognizable response. This guide is for site owners, designers editing content, and anyone whose forms or admin screens are suddenly refused.
What is happening
A request can pass through several gatekeepers before WordPress sees it. Any of them can answer 403, and they leave different fingerprints.
| Layer |
Typical fingerprint in headers and body |
Common triggers |
| Cloudflare WAF or custom rule |
server: cloudflare, a cf-ray header, a Cloudflare branded page such as “Sorry, you have been blocked” with a Ray ID, or “Error 1020 Access denied” |
Managed rules, custom rules, country blocks, bot settings, IP access rules |
| ModSecurity or host WAF |
The origin’s server header, a plain or host branded “Forbidden” page, sometimes “Not Acceptable” (406) instead. A log line with a rule id exists on the server |
Post content containing code, SQL keywords, long encoded strings, form payloads |
| Web server (Apache, Nginx, LiteSpeed) |
Apache: “Forbidden. You don’t have permission to access this resource.” Nginx: “403 Forbidden” with “nginx” under it. LiteSpeed: “Access to this resource on the server is denied!” |
File permissions, Options -Indexes with no index file, deny or Require rules in .htaccess |
| Security plugin |
The origin’s server header and a WordPress styled or plugin branded message |
Plugin firewall rules, IP or country blocks, login protection, blocked user agents |
| Password protection |
401 first with a browser login prompt; 403 if the realm is misconfigured or the IP is not allowed |
.htpasswd on wp-admin or a staging site |
| WordPress itself |
A 403 on admin-ajax.php or a REST route with a body such as -1, 0, or JSON with "code":"rest_cookie_invalid_nonce" |
Expired nonce, logged out session, missing capability |
Before you start
- Access: WordPress admin, your hosting control panel, SSH or SFTP if possible, and your Cloudflare account if the domain uses it.
- Backup: copy
.htaccess before editing it, and export security plugin settings before changing them.
- How to run commands: server commands run over SSH in the site folder, the one containing
wp-config.php, using wp or wp-cli depending on your host. curl runs on your own computer (built into macOS and Linux; on Windows use WSL or curl.exe in PowerShell).
- Do not turn off your WAF or security plugin site wide as the fix. Use it briefly only to confirm a cause, then whitelist the specific rule.
Step 1: Identify which layer sent the 403
Read the response in the browser
Repeat the failing action with DevTools open on the Network tab. Click the red 403 row. Under Headers, read the Response Headers. Under Response or Preview, read the body. Use the table above to match the fingerprint.
Read it from the terminal
curl -s -D - -o body.html https://example.com/PAGE_PATH/
grep -io "ray id[^<]*\|modsecurity\|wordfence\|forbidden\|access denied" body.html | head
Run both on your computer. Replace example.com/PAGE_PATH/ with the refused URL (for a refused page view; refused form posts are easier to read in DevTools). -D - prints response headers to the screen, -o body.html saves the body to a file, and -s hides progress. The grep line searches the body case insensitively for common block page phrases. Good output for a working page is HTTP/2 200. A 403 with server: cloudflare and a Ray ID in the body means Cloudflare blocked it. A 403 with server: Apache, nginx or LiteSpeed means your origin blocked it; continue with the steps below to find which part of the origin.
Check whether it is your IP
Repeat the action on mobile data with Wi-Fi off. If it works there, your office or home IP is blocked by an IP rule, rate limit or country block. If it fails everywhere, the content of the request or a server setting is the cause.
Step 2: ModSecurity and host WAF false positives
This is the classic cause of “403 when I click Update”, “403 when saving a page with code in it” and “403 when submitting a form”. ModSecurity inspects request bodies against a rule set, commonly the OWASP Core Rule Set (CRS). A blog post that contains a code sample, SQL words, a long base64 string or certain HTML can look like an attack.
Confirm and find the rule ID
Your host panel may show recent ModSecurity hits. In cPanel and WHM servers, look for ModSecurity Tools and its hits list. In Plesk, open Web Application Firewall and its log. If you have SSH and log access, search the error log:
grep -i "modsecurity" LOG_PATH | grep "example.com" | tail -n 20
Run over SSH. LOG_PATH is your site’s web server error log or the ModSecurity audit log; your host panel shows its location, often a logs folder in your home directory or a path such as /var/log/apache2/error.log on a server you manage. Replace example.com with your domain. tail -n 20 shows the last 20 matches. Good output, for the purpose of diagnosis, is a line like this:
ModSecurity: Access denied with code 403 (phase 2). Operator GE matched 5 at TX:anomaly_score. [id "949110"] [msg "Inbound Anomaly Score Exceeded (Total Score: 5)"] [hostname "example.com"] [uri "/wp-admin/post.php"]
This is example log output. Read it this way: code 403 confirms ModSecurity sent the 403. uri is the URL. With the OWASP CRS in anomaly scoring mode, rule 949110 is only the rule that adds up the score and blocks; it is not the rule that matched your content. Look at the lines logged just before it, with the same unique_id, for the rules that actually matched, for example a 942xxx rule (SQL injection detection) or a 941xxx rule (cross site scripting detection). Those contributing rule IDs are what you whitelist. No output means ModSecurity did not log a block for that domain, so look at the other layers.
Whitelist by rule ID, for that URL only
On shared hosting, send the rule ID and URL to your host and ask for a scoped exclusion. On cPanel servers you control, ModSecurity Tools can disable a rule, but that disables it for every site on the server. A narrower exclusion on Apache with ModSecurity 2 looks like this:
<LocationMatch "^/wp-admin/post\.php$">
SecRuleRemoveById RULE_ID
</LocationMatch>
This goes in the virtual host configuration or an include file your server admin designates for ModSecurity exclusions, not in a normal WordPress file, because many hosts do not allow ModSecurity directives in .htaccess. RULE_ID is the contributing rule ID from the log. LocationMatch limits the exclusion to that URL. Reload Apache after editing. Good result: the action now succeeds and other URLs remain protected. Bad result: Apache fails to start, which means the directive is not allowed in that context; undo it and ask your host.
On Nginx with ModSecurity 3 (libmodsecurity), the same idea is written as a rule that removes another rule at runtime:
SecRule REQUEST_URI "@beginsWith /wp-admin/post.php" "id:NEW_RULE_ID,phase:1,pass,nolog,ctl:ruleRemoveById=RULE_ID"
This goes in the ModSecurity configuration file loaded by Nginx, before the CRS rules. NEW_RULE_ID is a unique ID for your exclusion rule that does not clash with existing rules (choose one from the range your rule set reserves for local rules). RULE_ID is the rule to skip. phase:1 runs it early, pass and nolog let the request continue silently, and ctl:ruleRemoveById switches the matching rule off for this request only. Test the config with nginx -t before reloading; good output says the syntax is ok and the test is successful.
Where the false positive comes from one form field, CRS also supports ctl:ruleRemoveTargetById=RULE_ID;ARGS:FIELD_NAME, which skips only that field. That keeps the rule active for everything else and is the most precise exclusion.
Step 3: File and directory permissions
Confirm
A web server 403 on a whole page or folder, with the plain Apache, Nginx or LiteSpeed message and no WAF log line, often means the server cannot read the files. This is common after a restore, an upload by root, or an FTP client that set odd modes.
ls -la
ls -la wp-content/
Run over SSH in the site folder. Good output shows folders as drwxr-xr-x (755), files as -rw-r--r-- (644), and your site user as owner. wp-config.php may be stricter, such as 640 or 600, if your host recommends it. Bad output is a folder at 700 or 600, files at 600, or files owned by root.
Fix
find . -type d -exec chmod 755 {} \;
find . -type f -exec chmod 644 {} \;
Run over SSH in the site folder as the site user. . means the current folder and everything below it. -type d picks folders, -type f picks files, and -exec chmod sets the mode on each. Afterward, reapply any stricter mode your host recommends for wp-config.php. If you see “Operation not permitted”, the files are owned by another user; open a ticket so the host can change ownership. Never use 777: it does not fix ownership problems and many servers refuse to serve files from world writable folders.
Step 4: Options -Indexes and missing index files
If a URL that should be your home page or a folder returns 403, the server may be finding no index file and refusing to list the folder. Options -Indexes in Apache, or no autoindex on in Nginx, deliberately blocks directory listings, which is correct security. The 403 appears when index.php is missing or the server is not configured to look for it.
To confirm, check that index.php exists in the site folder with ls -la index.php. If it is missing (a failed upload or a malware cleanup that went too far), restore it from a fresh WordPress download of the same version. If it exists, confirm the server lists it as an index: Apache needs DirectoryIndex index.php index.html, and Nginx needs index index.php index.html; in the server block. Keep Options -Indexes; do not turn listings on to “fix” the 403.
Step 5: .htaccess deny rules and directory protection
Look for rules that refuse access
grep -rn -i "deny from\|require all denied\|require ip\|\[F\]\|AuthType" .htaccess wp-admin/.htaccess wp-content/.htaccess 2>/dev/null
Run over SSH in the site folder. -r searches recursively, -n shows line numbers and -i ignores case. The pattern matches the common Apache access control lines: old style Deny from, newer Require all denied and Require ip, rewrite rules ending in [F] (forbidden), and password protection (AuthType). Every match is a candidate; read each one and ask whether it should apply to the refused URL. No output means none of those files contain access rules.
Directory protection with .htpasswd
Password protecting wp-admin is a common hardening step. Done wrongly, it also blocks wp-admin/admin-ajax.php, which the front end of many sites uses for forms, carts and live search, so visitors see failures. If a wp-admin/.htaccess contains AuthType Basic, add an exception for admin-ajax.php:
<Files "admin-ajax.php">
Require all granted
</Files>
This goes in wp-admin/.htaccess, below the existing protection lines. It has no placeholders. On Apache 2.4 it allows anyone to reach that one file while the rest of wp-admin stays password protected. Good result: front end forms work and /wp-admin/ still prompts for a password. Bad result: a 500 error, which means your server does not allow this directive in .htaccess; remove it and ask your host. Also check that the AuthUserFile path points to an .htpasswd file that actually exists; after a migration it often points to the old server’s path.
Step 6: Security plugin rules, IP and country blocks
Wordfence, Solid Security, All In One WP Security, Sucuri and similar plugins have their own firewalls, IP block lists, country blocks and login lockouts. Their block pages usually name the plugin. To confirm:
- Open the plugin’s live traffic, firewall or blocked requests log and search for your IP (find it by searching “what is my IP”) and the UTC time of the block.
- If the log shows a matched rule for your request, use the plugin’s allowlist feature for that rule or URL. Wordfence, for example, lets you allowlist a blocked request from its firewall log.
- If you are locked out of the admin entirely, you can disable the plugin over SSH and re-enable it after fixing the setting:
wp plugin deactivate PLUGIN_SLUG
wp plugin activate PLUGIN_SLUG
Run over SSH in the site folder. PLUGIN_SLUG is the plugin’s folder name; list them with wp plugin list. Good output: Success: Deactivated 1 of 1 plugins. Some firewalls load before WordPress through auto_prepend_file in .user.ini or .htaccess, so they keep blocking even when deactivated; if so, the plugin’s documentation explains how to remove that line safely. Reactivate as soon as you have allowlisted your IP or fixed the rule.
Step 7: Cloudflare WAF events
Confirm and read the event
If the 403 page is branded by Cloudflare or shows a Ray ID, open the Cloudflare dashboard for the domain and open Security Events (at the time of writing, the Events tab under Security, then Analytics; older dashboards listed it as Security, then Events). Add a filter for the Ray ID from the block page, or for your IP address. The event shows the action (Block, Managed Challenge), the service (Managed rules, Custom rules, Rate limiting, IP access rules, Bot fight mode) and the specific rule ID and description.
The events view may be sampled for busy sites; narrow the time range if you cannot find the event.
Fix narrowly
- Custom rule or country block: edit the rule so it skips the path or IP you need, for example excluding
/wp-admin/ for your office IP.
- Managed rule false positive: create a WAF exception (skip rule) that skips that specific managed rule ID for the affected URL path, rather than turning off the managed ruleset.
- IP access rule: remove or change the block in the IP access rules list (at the time of writing under Security, Security rules; older dashboards put it under WAF, Tools).
- Error 1020: this means a firewall rule you or a colleague created matched; the Events entry names it.
Step 8: WordPress nonces and REST 403s
If the 403 comes from admin-ajax.php or /wp-json/ and the body is -1, 0 or JSON mentioning rest_cookie_invalid_nonce or rest_forbidden, WordPress itself refused. Usually a cached page holds an expired security token (nonce), or your session expired. Log out and in, and make sure your page cache does not cache pages with forms for longer than WordPress nonces stay valid (WordPress nonces are valid for up to a day by default). The cache guide covers exclusions in detail.
Advanced: Proving the layer with origin bypass and request replay
When a CDN and a server WAF both exist, isolate them. First reproduce the exact failing request outside the browser: in DevTools, right click the 403 row and choose Copy, then Copy as cURL (bash). Paste it in a terminal on your computer and confirm it returns 403. Then add a resolve flag to send the same request straight to your server:
curl ... --resolve example.com:443:ORIGIN_IP -s -o /dev/null -w "%{http_code}\n"
Run on your computer. The ... stands for the full copied command from DevTools, and ORIGIN_IP is your server IP from your hosting panel. --resolve forces the connection to that IP while keeping the correct host name and certificate. -o /dev/null discards the body and -w "%{http_code}\n" prints only the status code. If the CDN path returns 403 and the origin path returns 200, the CDN blocked it. If both return 403, the block is on your server. The copied command contains your login cookies, so do not paste it into tickets or chats; remove the -H 'cookie: ...' part first.
To find which request field triggers a WAF, trim the copied request: remove form fields one at a time and replay. The field whose removal turns the 403 into a 200 is the one to target with a field level exclusion, such as ctl:ruleRemoveTargetById on ModSecurity or a skip rule scoped to that path on Cloudflare.
When to contact your host
Open a ticket when the 403 comes from the origin and you cannot see the WAF logs, when file ownership is wrong, or when you need a ModSecurity exclusion on shared hosting. Send:
- The exact URL and action, and the UTC timestamp of each attempt.
- Your public IP address at the time.
- The response headers and the first lines of the 403 body.
- Any Cloudflare Ray ID, and whether the origin bypass test also returned 403.
- What changed recently: a new plugin, a migration, a security hardening step.
Ask for the matching rule ID and a scoped exclusion rather than having the firewall turned off. If the whole site is refusing everyone, go to the Emergency Room. The diagnostic tools can fetch the headers for you.