Guide
WordPress multisite cross-site admin access — detecting users editing sites they don't belong to
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_. 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_usermetarow 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_capabilitiesrows 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_usersalone. - 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.phpon 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-adminon which subsite. The web server access log has no concept ofblog_id— onlyHost:headers. - Network admin user listing is global, not relational.
Network Admin > Usersshows every user with a single aggregate role column. To answer "which subsites does Marcus have caps on?" you must querywp_usermetadirectly for everywp_row._capabilities - Super Admin is invisible in the per-site UI. When a Super Admin loads
wp-adminon subsite 12, they are not inwp_12_users. The capability check passes viais_super_admin()readingsite_adminsfromwp_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()orupdate_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-admintraffic looks normal. The signal that matters is not "how many admin requests" but "whichuser_idis hittingwp-adminon ablog_idthat does not match their homeblog_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_ 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 . If the rows were written by a plugin, deactivate the plugin until the behavior is understood — wp plugin deactivate .
Containment for compromised credentials (cause 3). Force a password reset, invalidate all sessions (wp user session destroy ), 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_meta row weekly and diff. Any new cross-blog cap row that did not come from a tracked admin action is automatically suspect._capabilities - Capability-write logging via a
must-useplugin that records everyadd_cap()and capability-keyupdate_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-useplugin that throws onis_super_admin()=truefrom 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_totalcross-site hits whereis_super_admin="1"are ongoing and expected — typically 50–500 per day per active Super Admin, depending on network size.wp_admin_requests_totalcross-site hits whereis_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 retention —
wp_admin_requests_total{is_super_admin="1"}resuming on auser_iddormant for 60+ days. Same signal shape as a fresh phish; the cause is forgotten privilege. - Plugin auto-elevation on activation —
wp_state_changes_total{category="plugin_activated"}followed by a cohort gainingwp_admin_requests_totalon subsites they never touched. - Credential stuffing followed by privilege probing —
wp_auth_attempts_total{result="success"}from a new IP geography, immediately followed bywp_admin_requests_total{endpoint="users"}cross-site. - Network-wide plugin update propagation —
wp_state_changes_total{category="plugin_updated"}firing across all subsites at once. Incompatibility shows aswp_php_warnings_total{blog_id="..."}spikes immediately after. switch_to_blog()capability bleed —wp_admin_requests_totalcross-site clustering on oneendpointacross many unrelated users, indicating custom code usingswitch_to_blog()without re-checking capabilities.
See what's actually happening in your systems
Connect your site. Logystera starts monitoring within minutes.