Fixing Elementor Infinite Loading Wheel Caused by admin-ajax.php 403 Forbidden

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 bodySource
HTML page “Attention Required” / “Sorry, you have been blocked”Cloudflare
Generic Nginx/Apache/OpenLiteSpeed 403 HTML pageWeb server rule or ModSecurity
Wordfence/security plugin block pagePlugin firewall
-1 or 0WordPress (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.
See also  How to Clean Orphaned wp_postmeta Rows Safely Using Direct SQL

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

  1. Go to Security → Events, filter by Path /wp-admin/admin-ajax.php and Action Block or Managed Challenge.
  2. Note the Service (Managed rules, Bot Fight Mode, Rate limiting, Custom rule) and the rule ID.
  3. 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.
  4. 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.
See also  Rank Math SEO Schema Warning: Missing Field "hasCourseInstance" on Custom Post Type

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 (www vs non-www, and http vs https in Settings → General).
  • In wp-config.php, confirm WP_HOME and WP_SITEURL match 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_level fixes 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.php to the URL whitelist or exempt the actions parameter.
  • 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 actions bodies. Exceeding client_max_body_size (Nginx) returns 413, and exceeding SecRequestBodyLimit in ModSecurity can return 403. Raise both, and also post_max_size and max_input_vars in php.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 status or csf -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-IP or X-Forwarded-For in the allowlist logic and confirm real-IP restoration (set_real_ip_from in Nginx).
See also  Fixing duplicate slug append (-2) on published WordPress pages with empty trash

Leave a Comment