DEV Community

Calin V.
Calin V.

Posted on

Your Page Builder Did Not Break. One of Three Endpoints Stopped Resolving.

A page builder editor that hangs on the loading spinner, or saves and then reports failure with no error worth reading, is almost never a security problem. It is a routing problem with a security plugin's fingerprints on it, and the reason it is hard to fix from the dashboard is that the dashboard cannot tell you which request stopped resolving.

Three endpoints decide this. /wp-admin/, admin-ajax.php, and the REST API at /wp-json/. Every builder editor I have had to debug uses all three, in that order, and a security plugin that changes site paths can move any of them. What follows is how to find out which one moved without uninstalling anything.

All of it runs against a site you own.

Probe all three from outside the session

You do not need an authenticated cookie for the first pass. All three endpoints answer an anonymous request in a distinctive way, and the shape of the answer tells you whether the path still routes.

SITE="https://example.com"

# 1. wp-admin: an anonymous GET should 302 to the login form.
curl -s -o /dev/null -w '%{http_code} -> %{redirect_url}\n' "$SITE/wp-admin/"

# 2. admin-ajax.php: no action parameter, so WordPress answers "0" with a 400.
#    The status is the point. A 404 here means the path is gone.
curl -s -o /dev/null -w '%{http_code}\n' "$SITE/wp-admin/admin-ajax.php"

# 3. REST API index: an anonymous GET returns the route index as JSON.
curl -s -o /dev/null -w '%{http_code}\n' "$SITE/wp-json/"
Enter fullscreen mode Exit fullscreen mode

Read it like this. A 302 from the first, a 400 from the second and a 200 from the third means all three default paths still resolve, and your editor problem is somewhere else. Cache, most likely. Skip to the cached-ajaxurl section.

Any 404 in that set is your answer. That endpoint has been changed and the server is not routing the new address to the old handler.

403 is a different animal and means a rule matched, not that the path moved. A firewall ruleset running at the rewrite layer will return 403 on a pattern match, and the editor's save payload can trip a rule written for SQL injection if it contains something that looks like one. If you get 403 on admin-ajax.php but 200 on /wp-json/, look at your ruleset before you look at your paths.

Tell a rewrite-layer 404 from a PHP 404

This distinction decides where you go looking, and it is measurable.

A rewrite-layer 404 is returned by Apache, LiteSpeed or nginx before PHP is invoked. A WordPress 404 is a full page load: bootstrap, database connection, template. The second one costs an order of magnitude more time.

# Compare a known static asset, a known WordPress 404, and the endpoint under test.
for U in "/favicon.ico" "/this-page-does-not-exist-9182" "/wp-admin/admin-ajax.php"; do
  printf '%-42s' "$U"
  curl -s -o /dev/null -w '%{http_code}  ttfb=%{time_starttransfer}s  size=%{size_download}\n' "$SITE$U"
done
Enter fullscreen mode Exit fullscreen mode

A response that lands near the static file's TTFB with a tiny body never reached PHP, and the fix is in your rewrite rules. A response that lands near the WordPress 404's TTFB, with a full HTML page in the body, means PHP ran and WordPress itself did not recognise the request, which usually means a filter is rewriting the URL in output but no server rule was written to match it.

You can confirm from headers too. WordPress sets X-Robots-Tag and a Link header on real page loads that a server-level 404 will not carry.

curl -sI "$SITE/wp-admin/admin-ajax.php" | grep -Ei 'server|link|x-robots|x-powered-by'
Enter fullscreen mode Exit fullscreen mode

The nginx trap

If your site runs nginx and the editor broke immediately after a path change, this is the cause more often than everything else combined.

Plugins in this category write rewrite rules into .htaccess. nginx does not read .htaccess. It never has, there is no module for it, and the file sits on disk being ignored while the plugin's interface reports that the rules were written. The plugin is not lying; it wrote the file. The server is not reading it.

# On the server. If this returns rules and you are on nginx, they are doing nothing.
grep -n -A20 'BEGIN' /var/www/example.com/public_html/.htaccess

# What is actually serving you:
curl -sI "$SITE" | grep -i '^server:'
Enter fullscreen mode Exit fullscreen mode

The rules have to go into the server block and the service has to be reloaded. The generic shape, for a plugin that has moved a path and given you the mapping:

location ~* ^/your-new-ajax-path/?$ {
    rewrite ^ /wp-admin/admin-ajax.php last;
}

# and for a relocated REST namespace
location ~* ^/your-new-api-path/ {
    rewrite ^/your-new-api-path/(.*)$ /wp-json/$1 last;
}
Enter fullscreen mode Exit fullscreen mode
sudo nginx -t && sudo systemctl reload nginx
Enter fullscreen mode Exit fullscreen mode

Test the config before reloading, every time. nginx -t is the difference between a reload and an outage.

The stale ajaxurl nobody looks for

This is the failure that survives a correct configuration, and it is worth knowing because the probes above will all come back clean while the editor stays broken.

WordPress prints the AJAX endpoint into the page as a JavaScript variable, and builders read it from there rather than constructing it. If a full-page cache captured the HTML before the path change, or a JS concatenator inlined the old value into a bundle, the browser is sending editor requests to an address that no longer exists while the server is perfectly happy to route the new one.

# What the served HTML currently tells the browser the AJAX endpoint is:
curl -s "$SITE" | grep -oE '(var )?ajaxurl[^;]{0,120};' | head

# And what your builder's own localised config carries:
curl -s "$SITE" | grep -oE '"ajaxurl":"[^"]+"' | head
Enter fullscreen mode Exit fullscreen mode

If either of those still shows the pre-change path, no amount of rewrite work will help. Purge in this order, because purging out of order just repopulates the layer you cleaned: CDN first, then server-level cache, then the WordPress cache plugin, then the browser. Any concatenated or minified JS bundles have to be regenerated, not just evicted.

While you are there, check the enqueued asset URLs too. A builder that loads its editor JS from a plugin directory whose name has been changed in output, without a matching server rule, produces a spinner that never resolves and zero errors in the PHP log, because the request never reached PHP.

curl -s "$SITE" | grep -oE 'src="[^"]+\.js[^"]*"' | head -20
Enter fullscreen mode Exit fullscreen mode

Snapshot first, then bisect

Take the restore point before you change anything else. It is two commands and it converts every subsequent step from a risk into an experiment.

cd /var/www/example.com/public_html
cp .htaccess ".htaccess.$(date +%F-%H%M)"
wp db export "pre-path-change-$(date +%F-%H%M).sql" --add-drop-table
Enter fullscreen mode Exit fullscreen mode

Then revert one endpoint at a time, in this order, testing a real save in the editor between each step:

  1. The AJAX endpoint. Most builder failures are here, because the editor talks to it continuously and the URL is baked into output rather than resolved at request time.
  2. The REST API path. Saving is what breaks, and it often breaks silently, which makes this the second most likely and the most annoying to diagnose.
  3. The admin path. On hosts that restrict what can be done with the admin directory, this one fails in ways that look like a permissions problem. Find where your plugin keeps that configuration rather than guessing at option names:
# Substitute your plugin's own option prefix for PREFIX. `wp plugin list` will remind you of the slug.
wp plugin list --status=active --field=name
wp option list --search='*PREFIX*' --fields=option_name,option_value | head -40
Enter fullscreen mode Exit fullscreen mode

Between each revert, log out and back in through your current login path. Session cookies are issued against the paths that were in force at the time, and stale cookies produce save failures that look exactly like a routing problem but are not one. Then resave permalinks, which regenerates rewrite rules without changing any setting.

The rule that prevents all of this

Anonymous traffic never touches these three endpoints. Automated scanning goes after the front end: plugin and theme directories, readme files, the login form, version strings in your page source. Changing those breaks the reconnaissance step of an automated attack. Changing admin-ajax.php breaks your editor and adds close to nothing, because nothing hostile was looking there.

So the configuration that costs you nothing and buys you most of the benefit is to change everything an unauthenticated request can see and leave the three endpoints your own tooling depends on exactly where they are.

That is not a compromise position. According to Patchstack's State of WordPress Security in 2026, 91% of the 11,334 vulnerabilities disclosed across the WordPress ecosystem in 2025 were in plugins, 46% of them had no patch available at the moment of disclosure, and the weighted median time from disclosure to first exploitation was five hours. Reducing what is reachable is the layer that does anything at all inside a five-hour window. A custom AJAX path is not part of that layer, and the same report's pentest of hosting defences, where internal WAFs, Cloudflare, Imunify360 and ModSecurity blocked 12% of attacks against known-exploited vulnerabilities and 26% on a broader test, is a reasonable prior for how much the layer underneath is contributing.

A control that gets deactivated in July because it broke an editor is protecting nothing in August. The setting you leave alone is part of the security configuration.

What is the endpoint failure that cost you the most time before you found it? I am after the one where every probe came back clean and the answer turned out to be somewhere else entirely.

Top comments (0)