Every form submission in a WordPress admin page or front-end handler that redirects afterward creates an opportunity for an open redirect attack: if the redirect destination is derived from user-supplied input — a $_GET['redirect_to'] parameter, a form hidden field, or a referrer header — an attacker can craft a link that sends legitimate users to a malicious site after logging in or completing a form. WordPress provides wp_safe_redirect() as a hardened alternative to wp_redirect(): it validates that the redirect destination is on the same host as the WordPress site (or in the allowed hosts list), and returns false instead of redirecting if the URL is external. Combined with nonce verification — which prevents the form handler from running at all if the request wasn’t initiated from a legitimate form — these two functions eliminate the most common redirect-based attacks in WordPress.
Problem: Your plugin's form processor reads $_POST['redirect_to'] and redirects to it after saving settings. An attacker could craft a form that redirects to a phishing site, and a logged-in admin who submits your form would be sent there.
Solution: Replace wp_redirect() with wp_safe_redirect() and verify a nonce before processing the form. Whitelist specific allowed redirect destinations for cases where cross-domain redirects are legitimately needed.
<?php
// ── Secure form handler pattern ────────────────────────────────────────
add_action( 'admin_post_save_my_plugin_settings', 'handle_my_plugin_settings_form' );
function handle_my_plugin_settings_form() {
// 1. Verify nonce — abort if missing or invalid
if ( ! isset( $_POST['_wpnonce'] )
|| ! wp_verify_nonce( $_POST['_wpnonce'], 'my_plugin_settings' ) ) {
wp_die( __( 'Security check failed.', 'textdomain' ), 403 );
}
// 2. Check capability
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( __( 'You do not have permission to do this.', 'textdomain' ), 403 );
}
// 3. Process the form
update_option( 'my_plugin_setting', sanitize_text_field( $_POST['setting'] ?? '' ) );
// 4. Build redirect URL — do NOT trust $_POST['redirect_to'] directly
$redirect = add_query_arg(
[ 'page' => 'my-plugin', 'status' => 'saved' ],
admin_url( 'options-general.php' )
);
// wp_safe_redirect — validates same-host, returns false for external URLs
wp_safe_redirect( $redirect );
exit;
}
// ── If you need to redirect to an allowed external domain ─────────────
add_filter( 'allowed_redirect_hosts', function ( $hosts ) {
$hosts[] = 'accounts.myapp.com'; // OAuth callback domain
return $hosts;
} );
// ── wp_safe_redirect vs wp_redirect comparison ─────────────────────────
$external_url = 'https://evil.example.com/phish';
$internal_url = admin_url( 'options-general.php?page=my-plugin&status=ok' );
// wp_redirect — follows any URL, vulnerable to open redirect
wp_redirect( $external_url ); // redirects to evil.example.com — UNSAFE
// wp_safe_redirect — validates host against site URL and allowed_redirect_hosts
$result = wp_safe_redirect( $external_url ); // returns false, does NOT redirect
if ( ! $result ) {
// Fallback to a safe internal URL
wp_safe_redirect( admin_url() );
}
exit;
NOTE: wp_safe_redirect() compares the host of the redirect URL against the host returned by home_url() and admin_url(), plus any hosts added via the allowed_redirect_hosts filter. Subdomain differences matter — app.example.com would be blocked if the site is at www.example.com unless you explicitly add it. Always call exit immediately after wp_safe_redirect() — the function only sends the Location header, it does not stop PHP execution. Forgetting exit means the rest of your handler runs even after the redirect is sent.