Root Cause Diagnosis
The Elementor editor initializes through POST requests to /wp-admin/admin-ajax.php (action=elementor_ajax), and a 403 on that request means something upstream of PHP rejected it, so the editor never receives its initialization JSON and the loading wheel never clears. The usual culprits are a WAF (ModSecurity/OWASP CRS, Cloudflare managed rules, aaPanel Nginx WAF, Wordfence) matching SQLi/XSS signatures in the actions parameter, which carries serialized widget HTML, CSS, and JS, or a web server rule that denies admin-ajax.php. Less commonly, WordPress itself returns 403 with a body of -1 or 0 after a nonce or capability failure.
Quick Fix
Identify the blocker first. In the browser, open DevTools → Network, filter on admin-ajax.php, and inspect the failing request’s response body:
| Response body | Source |
|---|---|
| HTML page “Attention Required” / “Sorry, you have been blocked” | Cloudflare |
| Generic Nginx/Apache/OpenLiteSpeed 403 HTML page | Web server rule or ModSecurity |
| Wordfence/security plugin block page | Plugin firewall |
-1 or 0 | WordPress (nonce/permission), not a WAF |
Then apply the matching immediate override.
ModSecurity (aaPanel, CyberPanel, or standalone): disable it for the domain temporarily to confirm.
# CyberPanel / OpenLiteSpeed: after disabling ModSecurity in the panel, restart
systemctl restart lsws
# Apache
systemctl reload apache2 # or httpd
# Nginx
nginx -t && systemctl reload nginx
Cloudflare: create a custom rule with action Skip (skip all managed rules and Bot Fight/Super Bot Fight) using this expression:
(http.request.uri.path eq "/wp-admin/admin-ajax.php" and http.request.method eq "POST" and http.cookie contains "wordpress_logged_in_")
If the editor loads after the override, the cause is confirmed and the steps below replace the blanket disable with a targeted exclusion.
Step-by-Step Resolution
1. Reproduce and fingerprint the block
Send one benign and one trigger payload to see whether the 403 depends on request content (WAF) or not (access rule):
# Benign
curl -s -o /dev/null -w "%{http_code}\n" -X POST "https://example.com/wp-admin/admin-ajax.php" \
-d "action=elementor_ajax&actions={}"
# Content that commonly trips CRS rules
curl -s -o /dev/null -w "%{http_code}\n" -X POST "https://example.com/wp-admin/admin-ajax.php" \
--data-urlencode 'action=elementor_ajax' \
--data-urlencode 'actions={"a":{"data":"<script>x</script> select * from wp_users"}}'
- Benign returns 400/200, trigger returns 403: WAF content rule.
- Both return 403: path-level deny rule, IP block, or Cloudflare rule.
2. Read the log that matches your stack
# aaPanel (Nginx/Apache site logs)
tail -f /www/wwwlogs/example.com.error.log
tail -f /www/wwwlogs/example.com.log | grep admin-ajax
# CyberPanel / OpenLiteSpeed
tail -f /usr/local/lsws/logs/error.log
tail -f /usr/local/lsws/logs/auditmodsec.log
tail -f /home/example.com/logs/example.com.error_log
# Apache + ModSecurity
tail -f /var/log/apache2/modsec_audit.log
# Nginx + libmodsecurity
tail -f /var/log/modsec_audit.log
In the ModSecurity audit entry, record the rule id (for example 942100, 941100, 920420) and the matched variable (typically ARGS:actions).
3. Exclude only the offending rules for admin-ajax.php
Add to your ModSecurity custom rules file. Paths:
- aaPanel:
/www/server/panel/vhost/nginx/modsecurity/(or the rules file shown in the WAF panel) - CyberPanel: Security → ModSecurity Rules Packs → Custom Rules
- Apache:
/etc/modsecurity/or/etc/apache2/mods-enabled/
SecRule REQUEST_URI "@streq /wp-admin/admin-ajax.php" \
"id:1000010,phase:1,pass,nolog,\
chain"
SecRule REQUEST_COOKIES_NAMES "@rx ^wordpress_logged_in_" \
"ctl:ruleRemoveTargetById=941000-941999;ARGS:actions,\
ctl:ruleRemoveTargetById=942000-942999;ARGS:actions"
Replace the ID ranges with the specific IDs from your audit log where possible. Reload the web server afterward. Rule IDs and range support differ between ModSecurity v2 and v3, so confirm that the log no longer shows the rule firing.
4. Check for web server rules denying admin-ajax.php
grep -rn "admin-ajax" /www/server/panel/vhost/ /etc/nginx/ /usr/local/lsws/conf/ 2>/dev/null
grep -rn "wp-admin" /www/server/panel/vhost/rewrite/ 2>/dev/null
Remove or narrow any block like this one:
location ~* /wp-admin/admin-ajax\.php {
deny all;
}
Hardening snippets that restrict /wp-admin/ by IP must exempt admin-ajax.php, because front-end features and the Elementor editor depend on it:
location = /wp-admin/admin-ajax.php {
allow all;
include fastcgi_params;
fastcgi_pass unix:/tmp/php-cgi-82.sock; # match your PHP version socket
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
nginx -t && systemctl reload nginx
5. Verify file ownership and permissions
A permission error on the file itself also returns 403.
ls -l /www/wwwroot/example.com/wp-admin/admin-ajax.php
# Expected: -rw-r--r-- www www (aaPanel) or the site user on CyberPanel
chown -R www:www /www/wwwroot/example.com # aaPanel
find /www/wwwroot/example.com -type d -exec chmod 755 {} \;
find /www/wwwroot/example.com -type f -exec chmod 644 {} \;
6. Cloudflare: find the exact rule and exempt it
- Go to Security → Events, filter by Path
/wp-admin/admin-ajax.phpand ActionBlockorManaged Challenge. - Note the Service (Managed rules, Bot Fight Mode, Rate limiting, Custom rule) and the rule ID.
- Under Security → WAF → Custom rules, create the Skip rule from the Quick Fix section and select only the components that blocked. For managed rule hits, skip the specific rule rather than all managed rules.
- If Bot Fight Mode is the source, it cannot be skipped by custom rule on Free plans. Disable it or move to Super Bot Fight Mode with a Skip rule.
7. Security plugins
If the 403 body is from Wordfence, iThemes/Solid Security, or similar, check Wordfence → Tools → Live Traffic for the blocked request and allowlist your admin IP (Wordfence → All Options → Brute Force Protection → Allowlisted IPs), or set the WAF to learning mode while you save a page in Elementor.
8. If WordPress returns 403 with -1 or 0
Nonce or session problem, not a WAF:
- Purge any page cache that serves the editor URL (
?action=elementor); exclude/wp-admin/*and*action=elementor*from caching. - Confirm the
wordpress_logged_in_*cookie is sent on the AJAX request and that the cookie domain matches the site URL (wwwvs non-www, andhttpvshttpsin Settings → General). - In
wp-config.php, confirmWP_HOMEandWP_SITEURLmatch the URL used in the browser.
Why This Fails for Some Users
- Paranoia level: OWASP CRS at paranoia level 2 or higher fires on benign page-builder payloads far more often than level 1. Lowering
tx.paranoia_levelfixes this more cleanly than removing rules one by one. - ModSecurity v2 vs v3: Rule exclusion syntax, range support in
ctl:ruleRemoveTargetById, and config locations differ between Apache/LiteSpeed (v2) and the Nginx connector (libmodsecurity v3). An exclusion that works on one may silently fail on the other. - Comodo vs OWASP rule sets: CyberPanel lets you select either pack. Their rule IDs do not overlap, so an exclusion written for CRS IDs does nothing under Comodo rules.
- aaPanel’s own WAF: The aaPanel Nginx WAF (btwaf) blocks on POST body keywords independently of ModSecurity. Disabling ModSecurity alone will not clear a btwaf block. Check its block log and add
admin-ajax.phpto the URL whitelist or exempt theactionsparameter. - Layered blocking: Cloudflare, the server WAF, and a plugin firewall can all block the same request. Fixing one reveals the next, so retest after each change rather than assuming the first fix is the only one.
- Large payloads: Pages with heavy global CSS or many widgets produce large
actionsbodies. Exceedingclient_max_body_size(Nginx) returns 413, and exceedingSecRequestBodyLimitin ModSecurity can return 403. Raise both, and alsopost_max_sizeandmax_input_varsinphp.ini. - IP blocking by fail2ban/CSF/Imunify360: Repeated 403 retries from the editor can trigger an IP ban, after which every request to the site returns 403. Check with
fail2ban-client statusorcsf -g <your-ip>. - Cloudflare plan limits: Free plans cannot skip Bot Fight Mode through custom rules and have fewer custom rule slots, so the Skip rule approach is unavailable or limited there.
- HTTP/2 and proxy chains: When a reverse proxy sits in front of the origin, the WAF may see altered headers or a different client IP, so allowlists keyed on your IP will not match. Use
CF-Connecting-IPorX-Forwarded-Forin the allowlist logic and confirm real-IP restoration (set_real_ip_fromin Nginx).