wp-includes/formatting.php:4479Clean a URL for safe output with esc_url(): it strips invalid characters, rejects disallowed protocols, and encodes ampersands for display. A URL using a protocol outside the allowed list (or an empty URL) comes back as an empty string, so a javascript: payload never reaches the page.
Checks and cleans a URL.
$urlstring$protocolsstring[]optionalnull$_contextstringoptional'display'stringEvery example is editable and runs in a real WordPress booted in your browser by WordPress Playground. Press Run, then edit the code: clicking away re-runs it. Nothing is sent anywhere until you do.
esc_url() strips characters that would break out of the attribute and drops protocols that are not allowed.
update_post_meta( 2, 'author_website', 'https://example.com/profile?id=7&ref="x' );
$website = get_post_meta( 2, 'author_website', true );
echo "raw: ", $website, "\n";
echo "escaped: ", esc_url( $website ), "\n\n";
echo 'javascript: URL becomes: ', var_export( esc_url( 'javascript:alert(1)' ), true );Use esc_url_raw() when storing or passing a URL to an HTTP call, not esc_url().
Pass the $protocols array to restrict which schemes are acceptable; anything else returns an empty string you can treat as invalid.
$promo_video = get_theme_mod( 'promo_video_url', '' );
$safe_src = esc_url( $promo_video, array( 'https' ) );
if ( '' !== $safe_src ) {
echo '<iframe class="promo-video" src="' . $safe_src . '" loading="lazy"></iframe>';
}By default the allowed protocols come from wp_allowed_protocols(), which includes mailto:, tel:, ftp:, and others; pass an explicit array when you want links narrower than that.
One hook fires while esc_url() runs, in this order:
Filters a string cleaned and escaped for output as a URL.
function esc_url( $url, $protocols = null, $_context = 'display' ) { $original_url = $url; if ( '' === $url ) { return $url; } $url = str_replace( ' ', '%20', ltrim( $url ) ); $url = preg_replace( '|[^a-z0-9-~+_.?#=!&;,/:%@$\|*\'()\[\]\\x80-\\xff]|i', '', $url ); if ( '' === $url ) { return $url; } if ( 0 !== stripos( $url, 'mailto:' ) ) { $strip = array( '%0d', '%0a', '%0D', '%0A' ); $url = _deep_replace( $strip, $url ); } $url = str_replace( ';//', '://', $url ); /* * If the URL doesn't appear to contain a scheme, we presume * it needs http:// prepended (unless it's a relative link * starting with /, # or ?, or a PHP file). */ if ( ! str_contains( $url, ':' ) && ! in_array( $url[0], array( '/', '#', '?' ), true ) && ! preg_match( '/^[a-z0-9-]+?\.php/i', $url ) ) { $url = 'http://' . $url; } // Replace ampersands and single quotes only when displaying. if ( 'display' === $_context ) { $url = wp_kses_normalize_entities( $url ); $url = str_replace( '&', '&', $url ); $url = str_replace( "'", ''', $url ); } if ( str_contains( $url, '[' ) || str_contains( $url, ']' ) ) { $parsed = wp_parse_url( $url ); $front = ''; if ( isset( $parsed['scheme'] ) ) { $front .= $parsed['scheme'] . '://'; } elseif ( '/' === $url[0] ) { $front .= '//'; } if ( isset( $parsed['user'] ) ) { $front .= $parsed['user']; } if ( isset( $parsed['pass'] ) ) { $front .= ':' . $parsed['pass']; } if ( isset( $parsed['user'] ) || isset( $parsed['pass'] ) ) { $front .= '@'; } if ( isset( $parsed['host'] ) ) { $front .= $parsed['host']; } if ( isset( $parsed['port'] ) ) { $front .= ':' . $parsed['port']; } $end_dirty = str_replace( $front, '', $url ); $end_clean = str_replace( array( '[', ']' ), array( '%5B', '%5D' ), $end_dirty ); $url = str_replace( $end_dirty, $end_clean, $url ); } if ( '/' === $url[0] ) { $good_protocol_url = $url; } else { if ( ! is_array( $protocols ) ) { $protocols = wp_allowed_protocols();Introduced in 2.8.0. Unchanged from 6.7.7 through 7.1.0.
Signature, return type and hooks compared across 5 parsed releases.
src/wp-includes/formatting.php, and regenerated for each WordPress release so it tracks the code rather than a snapshot of it.