Multisite security
When to use this skill
Use this skill whenever code runs on a WordPress multisite network:
- Using
switch_to_blog()to access another site's data. - Adding network admin pages (
network_admin_menu). - Enumerating sites with
get_sites(). - Reading a
blog_idfrom a request parameter or URL. - Performing actions that affect the whole network vs. a single site.
On multisite, site administrator and network administrator are different roles.
Network-level actions require manage_network or manage_network_options, and
switch_to_blog() must always be paired with restore_current_blog().
Related: see the capability-permission-checks skill for single-site capability
patterns.
Core principles (and why they matter)
- Site admin != network admin. A site administrator can do almost anything on their
own site, but they must not manage network settings or access other sites' data.
Network actions need
manage_network/manage_network_options. - Always pair
switch_to_blog()withrestore_current_blog(). Forgetting to restore leaves the rest of the request running in the wrong site context, leaking or corrupting data. - Re-check capabilities after switching. Capabilities are evaluated in the current
site context. After
switch_to_blog(), the capability context changes; re-verifycurrent_user_can()oruser_can()if you act on the new site. - Never trust a
blog_idfrom input. Validate it against allowed sites (e.g., sites the current user belongs to, or the network allowlist) before switching. - Keep per-site data isolated. Do not store site A's data in site B's options or tables unless the user explicitly authorized cross-site access.
is_super_admin()is for network-level checks only. Prefercurrent_user_can( 'manage_network' )for capabilities.
Step-by-step implementation
- Check
is_multisite()before running multisite-specific code. - For network admin pages, gate with
current_user_can( 'manage_network_options' ). - For site actions that read a
blog_idfrom input:- Cast to integer.
- Confirm the site exists (
get_site()). - Confirm the current user may act on it (
user_can( $user_id, 'manage_sites' )or membership checks).
- Wrap the operation in
switch_to_blog()/restore_current_blog(). - After switching, re-check capabilities if the action is privileged.
- Return errors rather than silently failing or defaulting to the current site.
Supporting references
| Reference | Load when |
|---|---|
| Multisite security checklist | Before final verification of the multisite security controls. |
| Secure multisite operations | Implementing validated site switching, membership checks, context restoration, and network option updates. |
Common AI mistakes / anti-patterns
Mistake 1 — switch_to_blog() with an unvalidated blog id
php// ❌ Insecure: any blog id can be requested, including sites the user does not own. $blog_id = (int) $_GET['blog_id']; switch_to_blog( $blog_id ); $posts = get_posts(); restore_current_blog();
php// ✅ Secure: validate the blog id and the user's relationship to it. $blog_id = isset( $_GET['blog_id'] ) ? absint( $_GET['blog_id'] ) : 0; $site = get_site( $blog_id ); if ( ! $site || ! is_user_member_of_blog( get_current_user_id(), $blog_id ) ) { wp_die( esc_html__( 'Invalid site.', 'my-plugin' ), 403 ); } switch_to_blog( $blog_id ); $posts = get_posts(); restore_current_blog();
Mistake 2 — Forgetting restore_current_blog()
php// ❌ Risky: subsequent code runs in the switched context. foreach ( $blog_ids as $blog_id ) { switch_to_blog( $blog_id ); do_something(); }
php// ✅ Secure: restore after each switch. foreach ( $blog_ids as $blog_id ) { switch_to_blog( $blog_id ); do_something(); restore_current_blog(); }
Mistake 3 — Using manage_options for a network action
php// ❌ Insecure: site admins have manage_options but cannot manage the network. if ( current_user_can( 'manage_options' ) ) { update_site_option( 'my_plugin_network_key', $value ); }
php// ✅ Secure: network actions require manage_network / manage_network_options. if ( ! current_user_can( 'manage_network_options' ) ) { wp_die( esc_html__( 'You do not have permission to manage network options.', 'my-plugin' ), 403 ); } update_site_option( 'my_plugin_network_key', $value );
Mistake 4 — Leaking data after a switch
php// ❌ Insecure: data fetched under site B is echoed on site A without re-checking. switch_to_blog( $blog_id ); $title = get_bloginfo( 'name' ); restore_current_blog(); echo $title;
php// ✅ Secure: validate the user may read the target site, then escape output. if ( ! is_user_member_of_blog( get_current_user_id(), $blog_id ) ) { return; } switch_to_blog( $blog_id ); $title = get_bloginfo( 'name' ); restore_current_blog(); echo esc_html( $title );
Mistake 5 — Assuming is_super_admin() covers every case
php// ❌ Less precise: is_super_admin checks the super-admin list, not a capability. if ( is_super_admin() ) { // do network thing }
php// ✅ Secure: prefer the explicit network capability. if ( current_user_can( 'manage_network' ) ) { // do network thing }
Mistake 6 — Per-site data in a network-wide cache or option
php// ❌ Insecure: site_transient and a global cache group are shared by every // site, so site A's private member list is served to site B's visitors. $members = get_site_transient( 'my_plugin_members' ); if ( false === $members ) { $members = my_plugin_query_members(); set_site_transient( 'my_plugin_members', $members, HOUR_IN_SECONDS ); }
php// ✅ Secure: per-site storage (or a key that includes the blog ID). $members = get_transient( 'my_plugin_members' ); if ( false === $members ) { $members = my_plugin_query_members(); set_transient( 'my_plugin_members', $members, HOUR_IN_SECONDS ); }
Also check wp_cache_add_global_groups() calls and custom tables without a
blog_id column: shared storage must never hold per-site private data.
Correct code examples
A complete network-safe routine that validates a blog id, switches context, and restores
it is in references/secure-multisite-operations.php.
Checklist
- Multisite-specific code checks
is_multisite()first. - Network admin actions use
manage_network/manage_network_options. -
blog_idvalues from input are cast to integers and validated. - The user is confirmed to be a member of / allowed on the target site before switching.
- Every
switch_to_blog()has a matchingrestore_current_blog(). - Capabilities are re-checked after switching sites if the action is privileged.
- Per-site data is not leaked into another site's context.
-
is_super_admin()is used only where the super-admin list is the correct gate. - Per-site data never goes into
*_site_transient,*_site_option, global cache groups, or shared tables without a blog ID in the key. - Users removed from a site lose access to that site's REST, AJAX, and export paths (
is_user_member_of_blog()rechecked, not cached).


