Guide

WordPress multisite cross-site admin access — detecting users editing sites they don't belong to

You glance at the admin access log on your WordPress multisite and see something wrong: user editormarcus — whose home blog is blogid=5 (marketing) — issuing POST /wp-admin/post.

1. Problem

You glance at the admin access log on your WordPress multisite and see something wrong: user editor_marcus — whose home blog is blog_id=5 (marketing) — issuing POST /wp-admin/post.php against blog_id=12 (corporate news), then blog_id=18 (careers), then back to 12 to publish. Marcus was never granted Editor on those sites. He is not a Super Admin. And yet wp-admin is responding with 200, not 403.

If you searched "wordpress multisite user accessing other site admin", "wordpress network super admin escalation", or "wordpress multisite security cross-site", this is the failure shape. A non-super user should be locked out of every subsite where they have no role row in wp__capabilities. When they are not, you have either (a) a quietly granted Super Admin role, (b) a plugin that bypasses capability checks, or (c) a corrupted wp_usermeta row that gave them caps on a site they were never invited to. This guide detects all three using wp_admin_requests_total correlated with the actor's home blog_id.

2. Impact

Cross-site admin access in a multisite network is a privilege boundary failure, and the blast radius is the entire network.

  • Silent privilege escalation. A user given Super Admin "just for the migration last quarter" who was never demoted retains the ability to publish, delete, and edit on every subsite indefinitely. The only audit trail is a wp_usermeta row updated 14 months ago.
  • Compromised editor accounts cascade. A phished editor with Super Admin becomes a foothold to inject malicious code into every theme on every site simultaneously. One phish, 80-site compromise.
  • Plugin-installed backdoors. Some "multisite manager" and SEO plugins elevate users for cross-site convenience and never revoke. The wp_capabilities rows they write are invisible from the network admin UI.
  • Compliance exposure. SOC 2 and ISO 27001 require demonstrable least-privilege. "Show me every user with admin access to subsite 12 in the last 90 days" cannot be answered from wp_12_users alone.
  • Insider threat goes undetected for months. An editor leaves the company, their account is left active because nobody is sure which subsites they had access to, and the account keeps issuing POST /wp-admin/post.php on five subsites for six weeks.

The tail of this is one privileged account, never demoted, becoming the lever that compromises 80 subsites in a single afternoon.

3. Why It’s Hard to Spot

WordPress multisite was designed around the fiction that "Super Admin can do anything, everyone else is scoped to their site." The codebase, the dashboard, and the audit surfaces all reinforce that fiction — and the moment it leaks, there is nothing in core to tell you.

  • No per-site access audit log. Core does not log who hit wp-admin on which subsite. The web server access log has no concept of blog_id — only Host: headers.
  • Network admin user listing is global, not relational. Network Admin > Users shows every user with a single aggregate role column. To answer "which subsites does Marcus have caps on?" you must query wp_usermeta directly for every wp__capabilities row.
  • Super Admin is invisible in the per-site UI. When a Super Admin loads wp-admin on subsite 12, they are not in wp_12_users. The capability check passes via is_super_admin() reading site_admins from wp_sitemeta. From the subsite's perspective, they don't exist — but they can edit every post.
  • Plugins write capabilities directly. A plugin calling $user->add_cap() or update_user_meta($id, "wp_12_capabilities", [...]) writes to the database without triggering any hook a security plugin would catch. The grant is invisible until used.
  • switch_to_blog() muddies attribution. A logged-in user on subsite 5 can switch context to subsite 12 mid-request. The URL says one host; the actual capability check ran against another.
  • Aggregate wp-admin traffic looks normal. The signal that matters is not "how many admin requests" but "which user_id is hitting wp-admin on a blog_id that does not match their home blog_id" — a comparison that does not exist in any default WordPress log.

4. Cause

The Logystera WordPress plugin emits wp_admin_requests_total on every request whose path matches wp-admin/* (excluding admin-ajax.php). The signal carries both request_blog_id (the subsite the admin request targets, from get_current_blog_id()) and user_blog_id (the primary_blog meta of the authenticated user). It also carries user_id, user_role, is_super_admin (0/1), and endpoint (the normalized wp-admin family — posts, users, plugins, network/sites, etc.).

When request_blog_id == user_blog_id, that is a normal admin request — a user editing on their own site. When the IDs differ and is_super_admin=1, that is also normal — a Super Admin doing their job. The interesting case is the third: request_blog_id != user_blog_id AND is_super_admin=0. A user with no network-wide privilege successfully completing an admin request on a subsite that is not their home. Either they have a legitimate per-site role row on that subsite, or something has granted them capabilities they should not have.

The supporting signal wp_environment_multisite is the precondition — without it, the request_blog_id / user_blog_id labels are not populated. wp_state_changes_total correlates admin requests that modify state (publish, delete, plugin activate, role change) with the cross-site context, distinguishing read-only browsing from active changes.

5. Solution

Root causes

Cross-site admin access typically has one of four causes, ranked by frequency in incident reviews.

Cause 1: Stale Super Admin grant. Most common. Someone was promoted during a migration and never demoted. Signal pattern: wp_admin_requests_total{is_super_admin="1"} on a user_id whose activity dropped near zero months ago and just spiked — a long-dormant account with sudden network-wide write activity.

Cause 2: Plugin granting capabilities directly. A multisite-aware plugin (membership manager, cross-site author plugin, SEO suite) calls add_cap() or writes wp__capabilities directly. Signal pattern: wp_state_changes_total{category="plugin_activated"} immediately precedes a cohort gaining wp_admin_requests_total cross-site.

Cause 3: Compromised credential. A phished editor account is being used by an attacker. Signal pattern: wp_auth_attempts_total{result="success"} from a new IP geography, immediately followed by wp_admin_requests_total cross-site on subsites the user has never touched, often hitting endpoint="users" or endpoint="plugins" rather than the user's usual endpoint="posts".

Cause 4: switch_to_blog() misuse in a custom plugin. Code calls switch_to_blog(12) and runs an action on behalf of the current user without correctly bracketing the capability check. Signal pattern: wp_admin_requests_total cross-site clusters on a single endpoint mapping to a specific custom feature, across many otherwise-unrelated users.

How to fix it

The fix path depends on which root cause you confirmed. In all cases, the immediate move is to stop the cross-site requests; the durable fix is to change the privilege model that allowed them.

Revoke stale Super Admin (cause 1). From wp-cli:

wp super-admin remove marcus_editor --network

Then verify:

wp user list --network --role=super-admin

Sweep the list. Every Super Admin must be a current operator. Any name that is "we'll demote them later" is the next incident.

Audit wp_usermeta capabilities (causes 2 and 4). Pull every per-blog capability row that does not match the user's home blog:

mysql -e "SELECT u.ID, u.user_login, m.meta_key
  FROM wp_users u
  JOIN wp_usermeta m ON m.user_id = u.ID
  WHERE m.meta_key REGEXP '^wp_[0-9]+_capabilities'
    AND m.meta_value NOT IN ('a:0:{}', '');"

For every row that should not be there, delete it directly via SQL or use wp user remove-role --url=. If the rows were written by a plugin, deactivate the plugin until the behavior is understood — wp plugin deactivate --network.

Containment for compromised credentials (cause 3). Force a password reset, invalidate all sessions (wp user session destroy --all), and rotate any application passwords. Then audit wp_state_changes_total for the last 48 hours under that user_id to determine what was modified — every post, plugin, and role change in the compromise window must be reviewed.

Architectural hardening. Three durable moves once the incident is closed:

  • Capability drift detection. Snapshot every wp__capabilities meta row weekly and diff. Any new cross-blog cap row that did not come from a tracked admin action is automatically suspect.
  • Capability-write logging via a must-use plugin that records every add_cap() and capability-key update_user_meta() call, making plugin-driven escalation visible at write-time rather than at use-time.
  • Disable Super Admin where possible. Most networks need zero standing Super Admins. A must-use plugin that throws on is_super_admin()=true from outside an allowlisted IP/Tailscale range removes the standing privilege almost entirely.

How to verify the fix

The signal that should change is wp_admin_requests_total{is_super_admin="0", request_blog_id!=user_blog_id} for the affected user_id. After the fix, that count for that user should drop to zero within minutes. Watch for 30 minutes under normal traffic.

Healthy baseline noise on this signal across an entire network looks like:

  • wp_admin_requests_total cross-site hits where is_super_admin="1" are ongoing and expected — typically 50–500 per day per active Super Admin, depending on network size.
  • wp_admin_requests_total cross-site hits where is_super_admin="0" should sit at near zero for most networks, with rare legitimate cases (a contributor invited to a sister site) appearing as low single-digit daily counts on a stable user-and-subsite pair.

A clean post-fix state for the incident user looks like:

grep 'wp_admin_requests_total' /var/log/logystera-agent/agent.log \
  | grep 'user_id="312"' \
  | grep -v 'request_blog_id="5"'
# → no output, or output only on subsites you explicitly re-granted

The verification timeframe is 30 minutes under normal traffic. If wp_admin_requests_total cross-site for the affected user drops to zero and wp_state_changes_total shows no further writes from that user on the previously affected subsites, the immediate cause is resolved. If you still see hits within an hour, the privilege source was not fully removed — go back to the wp_usermeta audit and look for a cap row you missed.

6. How to Catch This Early

Fixing it is straightforward once you know the cause. The hard part is knowing it happened at all.

This issue surfaces as wp_admin_requests_total.

Everything you just did manually — grep the agent log, compare request_blog_id to user_blog_id, filter out Super Admins, correlate with wp_state_changes_total, prove the cap row in wp_usermeta — Logystera does automatically. The same wp_admin_requests_total signal you just searched for is detected, charted, and alerted in real time, with the cross-site comparison built into the rule engine.

!Logystera dashboard — wp_admin_requests_total over time wp_admin_requests_total cross-site hits per user, last 24h, immediately after a stale Super Admin's account was reactivated.

!Logystera alert — cross-site admin escalation detected Critical alert fires within 60s of a non-super user issuing admin requests on a subsite that is not their home blog.

The default rule shape is: wp_admin_requests_total{is_super_admin="0", request_blog_id != user_blog_id} exceeding 5 hits in 5 minutes for any single user_id, on a network where wp_environment_multisite=1. The alert payload includes the user_id, the user's home user_blog_id, the targeted request_blog_id, the endpoint family hit, and a snapshot of wp_state_changes_total for the same user in the same window — so the responder sees not just "they hit the admin" but "they published a post and changed a plugin setting while they were there."

The fix is simple once you know the problem. The hard part is knowing it happened at all. Logystera turns this kind of failure from a quarterly compliance-review finding into a 60-second notification with the user, the subsite, the endpoint, and the state change that proves it.

7. Related Silent Failures

  • Stale Super Admin retentionwp_admin_requests_total{is_super_admin="1"} resuming on a user_id dormant for 60+ days. Same signal shape as a fresh phish; the cause is forgotten privilege.
  • Plugin auto-elevation on activationwp_state_changes_total{category="plugin_activated"} followed by a cohort gaining wp_admin_requests_total on subsites they never touched.
  • Credential stuffing followed by privilege probingwp_auth_attempts_total{result="success"} from a new IP geography, immediately followed by wp_admin_requests_total{endpoint="users"} cross-site.
  • Network-wide plugin update propagationwp_state_changes_total{category="plugin_updated"} firing across all subsites at once. Incompatibility shows as wp_php_warnings_total{blog_id="..."} spikes immediately after.
  • switch_to_blog() capability bleedwp_admin_requests_total cross-site clustering on one endpoint across many unrelated users, indicating custom code using switch_to_blog() without re-checking capabilities.

See what's actually happening in your systems

Connect your site. Logystera starts monitoring within minutes.

Logystera Logystera
Monitoring for WordPress and Drupal sites. Install a plugin or module to catch silent failures — cron stalls, failed emails, login attacks, PHP errors — before users report them.
Company
Copyright © 2026 Logystera. All rights reserved.