Sudo intentionally uses a few different names in different contexts:
- Product and UI name: Sudo. User-facing admin labels, notices, and diagnostics should use this name.
- Plugin file header / WordPress.org listing name: Sudo β Admin Action Gating. The plugin header and the readme
=== β¦ ===title use this longer, descriptive name so the public listing names the core function and satisfies WordPress.org's β₯5-latin-letter and trademark rules. The short brand Sudo is used in UI copy. Do not shorten the header to bare "Sudo" (only 4 latin letters β fails Plugin Check'splugin_header_unsupported_plugin_namerule). - Slug, text domain, option/meta prefixes, and hook prefixes:
wp-sudo/wp_sudo. These are stable identifiers and must not be renamed to match the shorter UI label because doing so would break translations, stored settings, integrations, and existing extension code. - GitHub repository:
dknauss/Sudo. Repository URLs should use the renamed GitHub repo, while package/folder identifiers may still intentionally usewp-sudo.
When adding or reviewing code, normalize display text toward Sudo but leave technical identifiers alone unless a migration explicitly owns that change.
The plugin text domain is wp-sudo, matching the intended WordPress.org slug, even though the product/UI name is Sudo. Do not rename the text domain to match the product name.
Maintainer commands:
composer i18n:make-pot # Regenerate languages/wp-sudo.pot
composer verify:i18n # Check that languages/wp-sudo.pot is currentcomposer verify:i18n is a release-readiness gate: it generates a temporary POT file with WP-CLI's wp i18n make-pot and fails if it differs from the committed languages/wp-sudo.pot. When adding or changing translatable strings, run composer i18n:make-pot intentionally and review the POT diff before committing.
The repository commits only languages/wp-sudo.pot plus safety placeholder files. Do not commit generated .po or .mo files unless the project starts maintaining reviewed translations directly.
Use the wp_sudo_gated_actions filter to add custom rules. Each rule defines matching criteria for admin UI (pagenow, actions, HTTP method), AJAX (action names), and REST (route patterns, HTTP methods). Custom rules appear in the Gated Actions table on the settings page.
Rule ingestion is normalized before caching. Invalid filtered entries are dropped
fail-closed per rule (required scalar metadata: id, label, category;
surface shapes must be array-or-null for admin, ajax, rest). If the
filter returns a non-array payload, WP Sudo falls back to built-in rules.
Custom rules protect only the surfaces they define: admin, AJAX, and/or REST. Application Password requests are covered when a custom rule defines REST criteria, because Application Passwords enter through the REST API. WP-CLI, Cron, and XML-RPC Limited mode use a built-in function-hook map for WP Sudo's core rules; they do not automatically discover arbitrary custom rules. If a third-party workflow needs non-interactive protection, either add an explicit integration with wp_sudo_check() / wp_sudo_require(), expose it through a REST rule that WP Sudo can match, or use the surface policy to disable the entry point. WPGraphQL is gated by its own surface-level policy rather than per-rule matching β in Limited mode, all mutations require a sudo session regardless of which action they perform. See WPGraphQL Surface below.
For POST replay, custom admin rules should declare a stash policy. WP Sudo
stores only top-level POST fields named in stash.post_fields; if no allowlist
is present, the user can still reauthenticate but the POST body is not replayed
automatically. Use post_mode => 'none' for uploads, file-editor saves, or
other requests that cannot be safely reconstructed from POST fields alone.
add_filter( 'wp_sudo_gated_actions', function ( array $rules ): array {
$rules[] = array(
'id' => 'custom.my_action',
'label' => 'My dangerous action',
'category' => 'custom',
'admin' => array(
'pagenow' => array( 'admin.php', 'edit.php' ), // string or string[]
'actions' => array( 'my_dangerous_action' ),
'method' => 'POST',
'callback' => function (): bool {
return some_extra_condition();
},
),
'ajax' => array(
'actions' => array( 'my_ajax_action' ),
),
'rest' => array(
'route' => '#^/my-namespace/v1/dangerous#',
'methods' => array( 'POST', 'DELETE' ),
'callback' => function ( $request ): bool { // optional: inspect params
return array_key_exists( 'confirm', $request->get_params() );
},
),
'stash' => array(
'post_mode' => 'allowlist',
'post_fields' => array( '_wpnonce', '_wp_http_referer', 'action', 'item_id' ),
),
);
return $rules;
} );For non-replayable POST actions:
'stash' => array(
'post_mode' => 'none',
),Secret-like field names are still omitted even when allowlisted. Matching is
case-insensitive and covers exact names plus high-signal suffixes such as
_api_key, _secret_key, _password, dashed equivalents, and camelCase
endings such as apiKey or accessToken.
To gate AJAX or REST endpoints from a third-party plugin, create a bridge
file and drop it into wp-content/mu-plugins/. Use a class-existence guard
so the rules are only added when the target plugin is active.
Example: WebAuthn security key registration (full bridge at
bridges/wp-sudo-webauthn-bridge.php):
<?php
// mu-plugins/wp-sudo-webauthn-bridge.php
defined( 'ABSPATH' ) || exit;
add_filter( 'wp_sudo_gated_actions', static function ( array $rules ): array {
// Only add rules when the WebAuthn Provider plugin is active.
if ( ! class_exists( 'WildWolf\WordPress\TwoFactorWebAuthn\Plugin' ) ) {
return $rules;
}
// Gate security key registration (two-step AJAX ceremony).
$rules[] = array(
'id' => 'auth.webauthn_register',
'label' => __( 'Register security key (WebAuthn)', 'wp-sudo' ),
'category' => 'users',
'admin' => null,
'ajax' => array(
'actions' => array( 'webauthn_preregister', 'webauthn_register' ),
),
'rest' => null,
);
// Gate security key deletion.
$rules[] = array(
'id' => 'auth.webauthn_delete',
'label' => __( 'Delete security key (WebAuthn)', 'wp-sudo' ),
'category' => 'users',
'admin' => null,
'ajax' => array(
'actions' => array( 'webauthn_delete_key' ),
),
'rest' => null,
);
return $rules;
} );Key patterns:
- Class-existence guard β
class_exists()check ensures rules are only added when the target plugin is active. Use the plugin's main class. - AJAX-only rules β set
'admin' => nulland'rest' => nullwhen the action is only accessible via AJAX. The gate'smatches_ajax()checks$_REQUEST['action']against theactionsarray. - Gate registration and deletion, not rename β gate security-sensitive operations (adding/removing authentication factors), not cosmetic ones.
- Category
'users'β groups the rules with other user-management actions in the Gated Actions table on the settings page.
To find the AJAX action names for a plugin, search its source for
wp_ajax_ hooks:
grep "wp_ajax_" /path/to/plugin/*.phpWP Sudo's built-in connectors.update_credentials rule is a good example of
how to gate one sensitive action on a broad shared endpoint without blocking the
entire endpoint.
- Route:
/wp/v2/settings - Methods:
POST,PUT,PATCH - Problem: the endpoint is used for many unrelated settings writes
- Narrowing strategy: only match when request params include connector credential setting names β using a two-tier registry-first union matcher
Matcher details (is_connector_api_key_setting_name):
On WP 7.0+, Tier 1 reads all connectors where authentication.method === 'api_key'
from wp_get_connectors() and gates their setting_name values. This catches
non-regex names like Akismet's wordpress_api_key. Tier 2 always runs as a
union fallback: ^connectors_[a-z0-9_]+_api_key$ covers pre-WP-7.0 installs
and connectors that auto-generate their setting name. Any connector with
method=api_key is gated automatically β no WP Sudo configuration needed.
For full details see
docs/connectors-api-reference.md β Two-tier matcher.
This pattern is useful when a third-party plugin reuses a generic REST or admin save path for multiple settings classes, but only one subset is security critical. In those cases:
- match the shared route/action normally
- add a callback that checks for the exact field names or payload shape that identify the dangerous write
- prefer over-matching inside the sensitive class to under-matching and missing a destructive credential or policy change
Use these helpers when you want to gate a custom operation without adding a
full wp_sudo_gated_actions rule.
Returns true when the user has an active sudo session (or is within the grace
window), otherwise false.
Only the current request's authenticated user can be checked β sudo sessions
are bound to a per-browser cookie, so passing a user ID other than the current
user always returns false. Omit the argument (or pass null) to check the
current user.
Returns true when sudo is active. If not active:
- Fires
wp_sudo_action_gatedwith surfacepublic_api. - Redirects to the challenge page in session-only mode by default.
- Returns
falsewhen redirecting is disabled or unavailable.
Accepted args:
user_id(int) β target user; defaults to current user.rule_id(string) β audit identifier; defaults topublic_api.require.redirect(bool) β defaulttrue; setfalseto receivefalseinstead of redirect.return_url(string) β optional URL for challenge cancel/return flow.
Example:
if ( ! wp_sudo_require( array( 'rule_id' => 'my-plugin.run_sensitive_task' ) ) ) {
return;
}
// Sensitive action runs only with active sudo.
my_plugin_run_sensitive_task();When the Gate blocks a cookie-authenticated AJAX or REST request (rather than
hard-blocking a non-interactive surface), it returns a recoverable sudo_required
response so the client can prompt the user to start a session and retry.
- REST β a
WP_Errorwith codesudo_required, a translated message naming the action and the keyboard shortcut, and error dataarray( 'status' => 403, 'rule_id' => <rule id> ). - AJAX (
wp_send_json_error) βcodeanderrorCodebothsudo_required,message/errorMessage(plain text),rule_id, and pass-throughslug/pluginwhen present (sowp.updatescan locate the originating DOM element).
On the next admin page load a set_blocked_transient notice provides a clickable
link to the challenge page. Treat rule_id as the stable field; message text is
localized and may change.
WP Sudo separates who may administer Sudo from general site-admin authority
using four dedicated capabilities, checked through a single helper. Route every
Sudo admin-surface capability check through it rather than calling
current_user_can( 'manage_options' ) directly.
Returns true when the user holds the given governance capability. $cap is one
of:
manage_wp_sudoβ administer Sudo settings and the Access tab.view_wp_sudo_activityβ view the Session Activity dashboard widget.export_wp_sudo_activityβ reserved for activity export (no surface gates on it yet).revoke_wp_sudo_sessionsβ force-revoke other users' active sudo sessions.
Decision order: multisite super admins always pass; break-glass recovery mode
(WP_SUDO_RECOVERY_MODE) grants manage_wp_sudo to the current user only; then
the strict cap check runs β user_can( $user_id, $cap ).
Since 4.0.0, governance is always strict. The
compatibilitymode and thesudo_can()alias were removed β see Migrating to 4.0.
Returns true when WP_SUDO_RECOVERY_MODE is defined and truthy in
wp-config.php. Break-glass escape hatch for the "last manager locked out"
scenario; leaving it enabled permanently effectively bypasses the governance
model.
Every Sudo admin surface was audited at 4.0.0 and confirmed to gate exclusively
on the dedicated Sudo capability family β no surface falls back to bare
manage_options. The surface-to-cap mapping is:
| Surface | Capability |
|---|---|
Settings page (add_options_page / add_network_options_page) |
manage_wp_sudo |
Settings page callback (render_settings_page) |
manage_wp_sudo |
| AJAX handlers (grant/revoke cap, revoke session, mu-plugin install/uninstall) | manage_wp_sudo |
| Dashboard widget | view_wp_sudo_activity |
Intentional non-gate uses of manage_options (these are correct behavior, not
gaps):
- The break-glass gate inside
wp_sudo_can()β whenWP_SUDO_RECOVERY_MODEis active,manage_optionsis the authority check that limits recovery to WordPress administrators. This is not an access gate replacingmanage_wp_sudo; it is an additional restriction applied during the emergency escape hatch. wp_sudo_map_governance_meta_cap()β mapsmanage_wp_sudotomanage_optionsduring recovery mode so WordPress core's own admin-page gate enforces the same check. Same recovery scope; same intent.- The Access tab informational panel β reads the list of users who hold
manage_optionsfor display purposes only. This is a read-only UI element, not an authorization gate.
Version 4.0.0 is a breaking release. Three changes affect integrators:
The unprefixed sudo_can() alias (shipped in 3.2.0, deprecated in 3.3.0 with a
documented removal target of 4.0.0) is gone. The promise is now delivered:
calling sudo_can() is a fatal undefined-function error. Replace it with
wp_sudo_can(), which has the identical signature and behavior:
wp_sudo_can( string $cap, ?int $user_id = null ): boolSearch-replace any remaining sudo_can( call sites with wp_sudo_can(. No other
change is needed β the parameters and return value are unchanged.
The compatibility value of the wp_sudo_governance_mode option used to make
wp_sudo_can() and wp_sudo_map_governance_meta_cap() delegate to
manage_options / manage_network_options instead of the dedicated
manage_wp_sudo capability family. That mode is removed. Governance is now always
strict: wp_sudo_can() delegates to user_can( $user_id, $cap ).
Why it existed, and why it's gone. WP Sudo 3.2.0 introduced a dedicated
capability model β the manage_wp_sudo family β to separate "who may administer
Sudo" from the generic site-admin manage_options capability. compatibility
mode was the transitional bridge for that change: it let a site keep
authorizing Sudo administration via the old manage_options check while its
administrators were being migrated onto the dedicated capabilities, so adopting
the stricter model could not lock an existing admin out before the capability
backfill had run. By 4.0.0 the bridge has done its job β the dedicated-capability
model is the established default, the 3.3.0 backfill grants the caps to existing
administrators automatically, and the 3.4.0-hardened WP_SUDO_RECOVERY_MODE
covers the "last manager locked out" recovery case the bridge was guarding
against. Removing the mode collapses governance to a single, auditable strict
path and eliminates a second capability-check code branch β less complexity and
less attack surface, in keeping with the 4.0.0 pre-public hardening baseline.
A site that still has wp_sudo_governance_mode stored is not broken β any stored
value is inert (treated as strict) β and 4.0.0 removes it automatically; no
manual database cleanup is required:
- On upgrade.
Upgrader::upgrade_4_0_0()deleteswp_sudo_governance_modeon the 3.x β 4.0.0 boundary, from both the per-site option store and (on multisite) network sitemeta. Deleting an absent option is a no-op, so it is safe on fresh installs upgrading through 4.0.0. - On detection (self-heal).
Admin::cleanup_inert_governance_mode_option()runs onadmin_init(priority 1) and deletes the option from both stores if it ever reappears after the version stamp is already 4.0.0 β for an authorized (manage_wp_sudo) admin loading any admin page. - The signal. After the option is cleared, an admin with
manage_wp_sudosees a single dismissible success notice confirming the leftover setting was removed (no action needed). There is no persistent warning and no_doing_it_wrong(); the developer/audit signal is thewp_sudo_inert_governance_mode_detectedaction, fired at most once per request when the cleanup deletes the option.
WP_SUDO_RECOVERY_MODE (see wp_sudo_is_recovery_mode()) remains the sole
break-glass path for a locked-out administrator.
If every holder of manage_wp_sudo is removed β for example, the capability was
accidentally revoked from the only administrator who had it β no one can reach the
Sudo settings page to re-grant it. WP_SUDO_RECOVERY_MODE is the way out:
- Add
define( 'WP_SUDO_RECOVERY_MODE', true );towp-config.php. - Log in (or reload) as an administrator who holds WordPress's
manage_optionscapability (any standard single-site admin). On multisite, the account must holdmanage_network_options(super admin). - Navigate to Settings β Sudo β Access and grant
manage_wp_sudoto the intended user(s). - Remove
define( 'WP_SUDO_RECOVERY_MODE', true );fromwp-config.phpimmediately after access is restored.
Why it works. While the constant is defined, wp_sudo_can( 'manage_wp_sudo' )
returns true for the current user β but only if they also hold manage_options
(single-site) or manage_network_options (multisite). Subscribers, editors, and
non-admin users gain nothing. Once the constant is removed, the dedicated-capability
check resumes and only explicitly granted users can access Sudo settings.
What it does not cover. A user who holds manage_wp_sudo without a WordPress
admin role (i.e. manage_options) cannot recover this way β recovery mode requires
the admin primitive cap. Use WP-CLI to re-grant the capability in that case:
wp user add-cap <user_login> manage_wp_sudoSee security-model.md Β§Break-glass recovery
for the full risk analysis of the recovery window and the audit hooks that fire
while WP_SUDO_RECOVERY_MODE is active.
First-run lockout safety. On a fresh install, the activating administrator
automatically receives all four Sudo governance capabilities during plugin
activation β the upgrade_3_3_0() backfill routine runs and grants
manage_wp_sudo to any existing administrator. A first-run lockout (no one holds
manage_wp_sudo after activation) can only occur if no WordPress administrator
existed at activation time, which is atypical. If it does occur, WP_SUDO_RECOVERY_MODE
is the recovery path.
- WordPress 6.4 (from 6.2). Among other things, 6.4 guarantees
wp_get_admin_notice(), so no compatibility shim is needed for it. - PHP 8.2 (from 8.0).
composer.jsonrequiresphp >=8.2; the CI test matrix drops the 8.0 and 8.1 lanes.
Confirm the host meets both minimums before upgrading.
Settings β Sudo now includes an internal Request / Rule Tester panel for diagnosing how WP Sudo would classify a representative request without executing it.
The first MVP supports three request shapes only:
adminajaxrest
Supported inputs:
- surface
- HTTP method
- full URL
- authenticated / unauthenticated toggle
- active sudo toggle
- network-admin context toggle
- REST auth mode (
cookie,application_password,bearer, ornone)
Current output includes:
- matched rule label and ID
- evaluated surface
- decision (
allow,gate,soft-block,hard-block) - whether stash/replay would be used
- explanatory notes
allowβ no matched rule, unauthenticated request, active sudo already present, or a surface policy that explicitly permits the requestgateβ an interactive admin request would be sent through the challenge page and use stash/replaysoft-blockβ an AJAX or cookie-authenticated REST request would be blocked in place and retried after sudo activationhard-blockβ a non-browser REST request would be rejected by current surface policy (for example, Application Password policy set to Limited or Disabled)
The tester uses WP_Sudo\Gate::evaluate_diagnostic_request() internally. That
method is intentionally side-effect-free:
- no redirects
- no request stashing
- no transients
- no audit hooks
- no mutation of live gate state
It is meant for operator troubleshooting and rule design, not for executing or replaying requests.
For Connectors specifically, this means the tester can verify whether a
representative /wp/v2/settings write would match
connectors.update_credentials and whether WP Sudo would allow, gate, or block
it under the current REST policy assumptions. It does not execute the
underlying core Connectors save flow, so it cannot by itself prove runtime
effects like key validation, masking, key-source precedence, or downstream
provider impact.
Sudo fires audit action hooks for external logging integration with WP Activity Log, Stream, and similar plugins. The live count is tracked in current-metrics.md.
// Session lifecycle.
do_action( 'wp_sudo_activated', int $user_id, int $expires, int $duration );
do_action( 'wp_sudo_deactivated', int $user_id ); // Also fires on password change (v2.8.0).
// Authentication failures.
do_action( 'wp_sudo_reauth_failed', int $user_id, int $attempts );
do_action( 'wp_sudo_lockout', int $user_id, int $attempts, string $ip );
// Action gating.
// $surface values: 'admin', 'ajax', 'rest' (cookie-auth REST), 'rest_app_password',
// 'cli', 'cron', 'xmlrpc', 'wpgraphql', 'public_api'.
// Note: 'rest' is browser/cookie-authenticated REST; 'rest_app_password' is an
// Application Password request. On 'cli'/'cron'/'xmlrpc' the $user_id is 0 (no
// authenticated user in those contexts), and only action_allowed/action_blocked
// fire there β never action_gated/action_passed.
// Since 4.1.0 the interactive effect-level backstop also fires action_blocked on
// the 'admin' surface (with the real $user_id) when a destructive effect
// (delete_user/delete_plugin/delete_theme/activate_plugin/upgrader_pre_install/
// export_wp) is reached through a non-enumerated handler while no sudo window is
// active. The backstop is silent on the allow path; the enumerated request flow
// owns the action_passed signal.
do_action( 'wp_sudo_action_gated', int $user_id, string $rule_id, string $surface );
do_action( 'wp_sudo_action_blocked', int $user_id, string $rule_id, string $surface );
do_action( 'wp_sudo_action_allowed', int $user_id, string $rule_id, string $surface ); // Unrestricted policy (v2.9.0).
do_action( 'wp_sudo_action_passed', int $user_id, string $rule_id, string $surface ); // Active session (v3.0.0).
do_action( 'wp_sudo_action_replayed', int $user_id, string $rule_id );
// Admin-escalation guard (v4.1.0, opt-in via the wp_sudo_guard_escalation filter,
// default OFF). High-severity, distinct from wp_sudo_action_blocked so external
// alerting can subscribe to escalation specifically. Fires when a NEWLY granted
// administrator/super-admin (or the deletion of an administrator target) is
// blocked because the actor lacks the promoting authority (promote_users on the
// target blog, or existing super-admin) or holds no active/grace sudo session.
// $target_id is the user being granted/deleted (not the actor). $rule_id is one of 'user.promote',
// 'user.super_admin', or 'user.delete'. The bundled Event_Recorder stores this as
// an `escalation_blocked` event with context severity=high.
do_action( 'wp_sudo_escalation_blocked', int $target_id, string $rule_id, string $surface );
// Rule diagnostics.
// Fires when a custom rule's REST-route regex fails to compile. $fail_closed is
// true when the malformed pattern caused the request to be gated anyway (v3.1.4).
do_action( 'wp_sudo_rule_regex_error', string $pattern, string $subject, bool $fail_closed );
do_action( 'wp_sudo_policy_preset_applied', int $user_id, string $preset_key, array $previous, array $current, bool $is_network );
// Tamper detection.
do_action( 'wp_sudo_capability_tampered', string $role, string $capability );
// Governance access-model transitions (v3.2.0).
do_action( 'wp_sudo_capability_granted', int $target_user_id, string $cap, int $granter_user_id, int $site_id );
do_action( 'wp_sudo_capability_revoked', int $target_user_id, string $cap, int $revoker_user_id, int $site_id );
do_action( 'wp_sudo_session_revoked', int $target_user_id, int $revoker_user_id, string $reason, int $site_id );
// Break-glass recovery usage (v3.4.0). Fires on every Sudo admin-page load
// while WP_SUDO_RECOVERY_MODE is active (unthrottled, for external loggers).
// The bundled Event_Recorder samples this to one stored `recovery_mode` event
// row per user per hour to avoid flooding the events table.
do_action( 'wp_sudo_recovery_mode_active', int $user_id );
// Rule diagnostics.
do_action( 'wp_sudo_gated_actions_missing_builtin_rules', array $missing_builtin_ids );wp_sudo_lockout adds source IP as a third argument as of v2.13.0. Existing
callbacks that register for two arguments continue to work unchanged.
WP Sudo ships an optional WSAL bridge at
bridges/wp-sudo-wsal-sensor.php. Install it as an mu-plugin to map
WP Sudo hooks into WSAL events.
Event mapping:
| WP Sudo hook | WSAL event ID |
|---|---|
wp_sudo_activated |
1900001 |
wp_sudo_deactivated |
1900002 |
wp_sudo_reauth_failed |
1900003 |
wp_sudo_lockout |
1900004 |
wp_sudo_action_gated |
1900005 |
wp_sudo_action_blocked |
1900006 |
wp_sudo_action_allowed |
1900007 |
wp_sudo_action_passed |
1900008 |
wp_sudo_action_replayed |
1900009 |
wp_sudo_capability_tampered |
1900010 |
wp_sudo_policy_preset_applied |
1900011 |
wp_sudo_escalation_blocked |
1900012 |
wp_sudo_session_revoked |
1900013 |
wp_sudo_recovery_mode_active |
1900014 |
wp_sudo_capability_granted |
1900015 |
wp_sudo_capability_revoked |
1900016 |
wp_sudo_gated_actions_missing_builtin_rules |
1900017 |
wp_sudo_rule_regex_error |
1900018 |
wp_sudo_recovery_mode_active fires on every recovery-mode page load by
design; the bridge throttles it to one event per user per hour (mirroring
the bundled Event_Recorder's sampling) to avoid flooding a table-backed
activity log. The diagnostic-only wp_sudo_inert_governance_mode_detected
hook is intentionally not mapped.
The bridge is inert when WSAL APIs are unavailable.
WP Sudo ships an optional Stream bridge at
bridges/wp-sudo-stream-bridge.php. Install it as an mu-plugin to map
WP Sudo hooks into Stream records.
Record mapping:
- Connector:
wp_sudo - Context:
wp_sudo - Action: derived from hook (
activated,deactivated,reauth_failed,lockout,gated,blocked,allowed,passed,replayed,policy_preset_applied,capability_tampered) - Args/meta: always includes
source=wp-sudoandhook, plus hook fields such asuser_id,rule_id,surface,attempts,ip,expires,duration,preset_key,previous,current, andis_networkwhere applicable.
The bridge supports late Stream availability (mu-plugin loads before
regular plugins) by deferring registration to plugins_loaded when
needed. It remains inert when Stream APIs are unavailable.
For operators who treat Stream or WSAL as their canonical audit destination,
a planned External Audit Mode setting will allow WP Sudo to stop writing
to its own wpsudo_events table while keeping all audit hooks (and
therefore both bridges) firing normally. The dashboard widget's Active
Sessions and Policy Summary panels are unaffected; the Recent Events panel
switches to a bridge-status tile linking to the destination plugin.
Activation is gated by a bridge-presence preflight to prevent silent audit
loss. See docs/external-audit-mode-spec.md
for the full design. Not scheduled; optional Phase 5 of the v3.1βv3.3 plan.
API-only, by design. None of the filters below has a Settings β Sudo control. The Settings page deliberately exposes only the everyday knobs (session duration, quick policy presets, and the entry-point policies for REST App Passwords / CLI / Cron / XML-RPC / WPGraphQL). Advanced and security-sensitive behaviour is configured in code β a filter or a constant (see Constants (no Settings UI) below) β so the UI stays uncluttered and the dangerous switches are not a click away. This is the project's standing pattern: when a knob is rarely needed or carries a footgun, it lives in the API, not the UI.
| Filter | Description |
|---|---|
wp_sudo_gated_actions |
Add, modify, or intentionally remove gated action rules. Site Health warns when built-in rule IDs are missing after filtering. |
wp_sudo_grant_session_on_login |
Whether to grant a sudo session automatically on browser login (apply_filters( 'wp_sudo_grant_session_on_login', true, $user )). Default true. Return false for shared-terminal/kiosk hardening or SSO control. Caution: suppressing the grant for users without a usable WordPress password (common with SSO) makes gated actions unreachable for them β only suppress for users who can pass the password challenge. Since 3.3.0. |
wp_sudo_two_factor_window |
2FA authentication window in seconds (default: 300). Clamped to 60β900 seconds (1β15 minutes). |
wp_sudo_requires_two_factor |
Whether a user needs 2FA for sudo (for third-party 2FA plugins). |
wp_sudo_validate_two_factor |
Validate a 2FA code (for third-party 2FA plugins). |
wp_sudo_log_passed_events_enabled |
Toggle recording of action_passed dashboard events. Default true; intended for explicit code-level overrides only. |
wp_sudo_critical_options |
The option names gated by the built-in options.critical rule (default: siteurl, home, admin_email, new_admin_email, default_role, users_can_register). Removing an entry silently un-gates that option β narrow the built-in protection set with care. |
wp_sudo_sensitive_stash_keys |
Lowercase field-name keys omitted from a stashed request before replay (default includes password, user_pass, pass1/pass2, token, secret, β¦). Matched case-insensitively, including nested keys. Over-matching is safe (the field is dropped and the user resubmits); under-matching risks replaying a secret. |
wp_sudo_cookie_secure |
Whether session/2FA cookies set the Secure flag (default is_ssl() || force_ssl_admin()). Returning false on production HTTPS exposes the cookie over plain HTTP β change only for known reverse-proxy/TLS-termination setups. |
wp_sudo_wpgraphql_classification |
Classify WPGraphQL body as mutation or query (persisted-query support). |
wp_sudo_wpgraphql_bypass |
Bypass WPGraphQL Limited-mode gating for specific requests. |
wp_sudo_guard_escalation |
Master on/off switch for the admin-escalation guard (apply_filters( 'wp_sudo_guard_escalation', false )). Default false (guard OFF). Return true to block a newly granted administrator (single-site) / super-admin (multisite) β and to alarm on administrator deletion β unless the actor both holds the promoting authority (promote_users on the target blog, or existing super-admin for grant_super_admin) and has an active or grace sudo session. (Sudo is reauthentication, not authorization: a low-privilege account can hold a session, so the authority check is what stops an under-privileged actor.) Effect-level (capabilities-meta write + grant_super_admin), so it applies on every surface; defers on CLI/Cron/XML-RPC and on Unrestricted REST Application-Password requests. Since 4.1.0. |
wp_sudo_allow_escalation |
Allowlist a specific administrator/super-admin grant past the escalation guard (apply_filters( 'wp_sudo_allow_escalation', false, int $target_id, mixed $context )). Default false. Return true to let a trusted provisioner (SSO/SAML/OIDC, directory sync) through. $context varies by path: the incoming capabilities array on the single-site promote path, the string 'super-admin' on the multisite path, or 'delete' on the admin-deletion path. Since 4.1.0. |
Defined in wp-config.php (or earlier than plugin load). Like the filters
above, none has a Settings β Sudo control β these are deliberately code-only
break-glass / environment switches.
| Constant | Effect | Since |
|---|---|---|
WP_SUDO_ALLOW_ESCALATION |
Define true to bypass the admin-escalation guard entirely. Checked first, before any session or capability read, so deployment, migration, and sole-admin recovery flows are never hard-blocked by the guard. Constant form of the wp_sudo_allow_escalation filter. |
4.1.0 |
WP_SUDO_RECOVERY_MODE |
Break-glass recovery. While defined, any user holding manage_options (manage_network_options on multisite) regains full Sudo governance access regardless of role β the escape hatch for a misconfigured/last-manager lockout. Weakens the governance model while set; remove as soon as normal access is restored. See wp_sudo_is_recovery_mode(). |
3.4.0 |
WP_SUDO_DISABLE_PASSED_EVENT_LOGGING |
Define true to stop recording action_passed dashboard events (reduced audit visibility for actions performed during an active sudo session). Checked before the wp_sudo_log_passed_events_enabled filter, which can still override per-request. |
3.0.0 |
Internal/structural constants (WP_SUDO_VERSION, WP_SUDO_PLUGIN_DIR,
WP_SUDO_PLUGIN_URL, WP_SUDO_PLUGIN_BASENAME, WP_SUDO_MU_LOADED) are set by
the bootstrap/MU loader, not operator knobs β do not define them by hand.
Planned (not yet implemented): if gating of editor site-design writes (
/wp/v2/global-styles,/wp/v2/font-families) is added, it will follow this same pattern β a default-OFF filter opt-in, not a Settings field β so the gray-area routes stay out of the UI unless an operator explicitly enables them. See.planning/post-4.1.0-dev-scopes.md(route inventory).
Action (not a filter): wp_sudo_render_two_factor_fields β do_action( 'wp_sudo_render_two_factor_fields', WP_User $user ) echoes 2FA input fields on the challenge form for third-party 2FA plugins. See two-factor-integration.md.
When the optional MU loader cannot resolve the main plugin file path, it emits:
do_action( 'wp_sudo_mu_loader_unresolved_plugin_path', array $file_candidates );Use this for operational visibility on non-canonical plugin layouts or broken deploy states where the shim is present but the main plugin path is unresolved.
Two test environments are used deliberately β choose based on what you are testing:
Unit tests (tests/Unit/) use Brain\Monkey to mock all WordPress functions. Fast (~0.3s total). Run with composer test:unit. Use for: request matching logic, session state machine, policy enforcement, hook registration, settings sanitization.
Integration tests (tests/Integration/) load real WordPress against a MySQL database via WP_UnitTestCase. Run with composer test:integration (requires one-time setup β see CONTRIBUTING.md). Use for: full reauth flows, real bcrypt verification, transient TTL and cookie behavior, REST and AJAX gating, Two Factor interaction, multisite session isolation, upgrader migrations.
When in doubt: if the test needs a real database, real crypto, or calls that cross class boundaries in production, write an integration test.
Static analysis:
composer analyse:phpstanruns PHPStan.composer analyse:psalmruns Psalm with the WordPress Psalm plugin/stubs.composer analyseruns both analyzers.
Code style: composer lint (PHPCS, WordPress-Extra + WordPress-Docs + WordPressVIPMinimum rulesets). Auto-fix with composer lint:fix.
Manual testing: see tests/MANUAL-TESTING.md for step-by-step verification procedures against a real WordPress environment.
Returns true if the user has an unexpired sudo session with a valid token. This is the primary check used throughout the plugin. Returns false and defers meta cleanup if the session has expired within the grace window (see is_within_grace()).
Returns true when the session has expired within the last GRACE_SECONDS (120 s) and the session token still matches the cookie. Used by the Gate at interactive decision points (admin UI, REST, WPGraphQL) to allow in-flight form submissions to complete after the session timer expires.
Session binding is enforced during the grace window β verify_token() is called before returning true. A stolen cookie on a different browser does not gain grace access.
The admin bar UI uses is_active() only; it always reflects the true session state.
Creates a new sudo session: generates a token, writes user meta, sets the httponly cookie, and fires wp_sudo_activated. Returns true on success. Also called automatically by Plugin::grant_session_on_login() on successful browser-based login (wp_login hook); that automatic grant can be suppressed via the wp_sudo_grant_session_on_login filter.
Ends the user's sudo session: clears the session user meta, expires the httponly cookie, and fires wp_sudo_deactivated. Called on explicit toolbar deactivation and automatically on password change.
Class constant (int 120). The length of the grace window in seconds. Can be referenced in custom code that inspects session state.
WP Sudo adds WPGraphQL as a fifth non-interactive surface alongside WP-CLI, Cron, XML-RPC, and Application Passwords. The policy setting key is wpgraphql_policy (stored in wp_sudo_settings). The three-tier model applies: Disabled, Limited (default), Unrestricted.
How gating works. WPGraphQL does not use the WordPress REST API pipeline β it dispatches requests via rewrite rules at parse_request. WP Sudo hooks into WPGraphQL's own graphql_process_http_request action, which fires after authentication but before body reading, regardless of how the endpoint is named or configured. In Limited mode, WP Sudo decodes JSON bodies, GET/form query params, and multipart operations payloads, scans inline GraphQL documents for a top-level mutation operation token, and blocks mutations unless the requesting user has an active sudo session.
Why surface-level rather than per-action. The action registry rules are keyed to WordPress action hooks β activate_plugin, delete_user, wp_update_options, etc. β that fire regardless of entry surface. WPGraphQL mutations do not reliably fire those same hooks; they dispatch through WPGraphQL's own resolver chain, and the mapping from mutation name to WordPress hook depends entirely on how each resolver is implemented. Per-action gating would therefore require either (a) parsing the GraphQL request body to extract operation names and maintaining a mutationβhook mapping across the full WPGraphQL ecosystem, or (b) a new WPGraphQL-specific rule type separate from the hook-based registry. Both approaches carry significant ongoing maintenance cost. The surface-level heuristic β tokenize the request body to detect a top-level mutation operation β is reliable for the primary use case (headless deployments where mutations come from automated clients, not interactive users) and the wp_sudo_wpgraphql_bypass filter provides the escape hatch for mutations that should not require a sudo session (see below).
Headless deployments. The Limited policy requires both a recognized WordPress user and an active sudo session cookie. For frontends running at a different origin, this means mutations will be blocked in most configurations β the sudo session cookie is browser-bound and can only be created via the WordPress admin UI. See WPGraphQL: Headless Authentication Boundary in the security model for full details and per-deployment policy recommendations.
Persisted queries. The default heuristic extracts inline GraphQL documents from the request body and scans each for a top-level mutation operation token (see extract_wpgraphql_documents() and wpgraphql_document_contains_mutation() in includes/class-gate.php). A WPGraphQL Persisted Queries (or APQ) request whose body carries only an operation id or hash (id, queryId, documentId, or an extensions.persistedQuery object) contains no inline document β the tokenizer cannot determine the operation type, so the request is blocked by default (fail-safe). Use wp_sudo_wpgraphql_classification to classify persisted requests as query so read-only persisted operations can pass through. If all persisted requests should pass through, use Unrestricted policy.
Block-string tokenizer. wpgraphql_document_contains_mutation() mirrors graphql-php's lexer so the gate sees the same operation structure the server will execute. GraphQL block strings ("""β¦""") recognize exactly one escape sequence β \""" (an escaped triple-quote) β and have no \\ escaped-backslash rule, unlike ordinary "β¦" strings; only an unescaped """ terminates the block. So a sequence like \\""" is a literal backslash followed by an escaped \""", which leaves the block string unterminated β graphql-php then raises an Unterminated string syntax error and executes nothing, so there is no mutation to gate. Verified against webonyx/graphql-php src/Language/Lexer.php (readBlockString()), where the escape branch fires only when a backslash is immediately followed by three quote characters. When editing the tokenizer, preserve this equivalence β do not switch to an "even number of backslashes terminates" model, which would diverge from the real lexer.
Classifies a GraphQL request body as mutation or query before the default decoded-payload classifier is used.
/**
* @param string $classification '' by default; return 'mutation' or 'query'.
* @param string $body The raw GraphQL request body.
* @return string
*/
apply_filters( 'wp_sudo_wpgraphql_classification', '', $body );- Return
'mutation'to force mutation handling. - Return
'query'to force non-mutation handling. - Return any other value to fall back to default decoded-payload classification.
Fires in Limited mode before mutation detection. Return true to allow the request through without sudo session checks. Does not fire in Disabled or Unrestricted mode β those policies return before this point.
/**
* @param bool $bypass Whether to bypass gating. Default false.
* @param string $body The raw GraphQL request body.
* @return bool
*/
apply_filters( 'wp_sudo_wpgraphql_bypass', false, $body );JWT authentication example. The wp-graphql-jwt-authentication plugin adds login and refreshJwtAuthToken mutations. These must bypass WP Sudo because they are the authentication mechanism β the login mutation is sent by unauthenticated users who cannot have a sudo session. Add this to an mu-plugin or theme:
add_filter( 'wp_sudo_wpgraphql_bypass', function ( bool $bypass, string $body ): bool {
if ( $bypass ) {
return $bypass;
}
$payload = json_decode( $body, true );
$query = is_array( $payload ) && is_string( $payload['query'] ?? null )
? $payload['query']
: '';
// Exempt only documents whose first top-level mutation field is a JWT
// authentication operation. Anchoring to the document shape matters: a
// `login` alias, a field name like `loginName`, or the substring inside
// a string argument elsewhere in the body must NOT trigger the bypass.
return (bool) preg_match(
'/^\s*mutation\b[^{]*\{\s*(login|refreshJwtAuthToken)\s*[({]/',
$query
);
}, 10, 2 );A bypass filter is security-sensitive in the over-match direction: every request it passes skips Limited-mode gating entirely. A naive substring check such as str_contains( $body, 'login' ) would let any mutation through ungated as long as the string login appears anywhere in the body β in an alias, a field name like loginName, or a string argument. Prefer under-matching (a legitimate auth mutation gets challenged β recoverable friction) over over-matching (a destructive mutation passes silently). Note one residual limit of the anchored example above: a document that pairs an auth operation with a second top-level mutation field (mutation { login(...) {...} deleteUser(...) {...} }) would still match on the first field. If that matters in your deployment, validate the complete document or use a persisted-query allowlist instead of pattern matching.