TechEarl

Disable WordPress oEmbed (Discovery Links, wp-embed.js, and /embed/)

How to disable WordPress oEmbed: stop the discovery links, the wp-embed.js script, the oEmbed REST route, and the /embed/ URLs, while keeping the YouTube-style embeds you paste in posts working.

Ishan Karunaratne⏱️ 14 min readUpdated
Share thisCopied
How to disable WordPress oEmbed: a snippet that removes the oEmbed discovery links and wp-embed.js from the head, drops the oEmbed REST route and /embed/ rewrite rules, and keeps known providers working.

Disabling WordPress oEmbed means turning off the provider (the discovery links, wp-embed.js, the oEmbed REST route, and the per-post /embed/ URLs) while leaving the consumer in place, so the YouTube and Twitter embeds you paste into posts keep working.

To strip the oEmbed footprint from your page head, this is the minimum that removes what visitors actually download, the discovery links and the wp-embed.js script:

php
add_action( 'init', function () {
    remove_action( 'wp_head', 'wp_oembed_add_discovery_links' );
    remove_action( 'wp_head', 'wp_oembed_add_host_js' );
}, 9999 );

That handles the front-end output. But oEmbed has more moving parts than two head lines (a REST route, rewrite rules, the /embed/ endpoints), and the thing people get wrong is removing too much and breaking the YouTube and Twitter embeds they actually use. So it is worth understanding what oEmbed is doing before you reach for the full removal.

Provider vs. consumer: the distinction that matters

WordPress 4.4 (December 2015) made every site both an oEmbed provider and an oEmbed consumer, and these are two completely different features that people conflate.

  • Consumer is the half you use on purpose: paste a YouTube, Twitter/X, or Vimeo URL on its own line in a post and WordPress turns it into an embedded player. That is almost certainly something you want to keep.
  • Provider is the half that runs whether you want it or not: your site exposes its own posts so that other WordPress sites can embed them as live iframes. To support that, core prints discovery <link> tags in your head, loads wp-embed.js, registers an oEmbed REST route, and creates an /embed/ URL for every post.

Almost nobody needs the provider side. It exists so a stranger's blog can drop an iframe of your post into theirs, which is rare, and it costs you head output, a script, a REST endpoint, and a pile of /embed/ URLs on every site that has it. The goal of "disabling oEmbed" for most people is: turn off the provider entirely, keep the consumer so your pasted embeds still work.

What the provider side prints and registers

Four things, all wired up in wp-includes/default-filters.php:

php
add_action( 'wp_head', 'wp_oembed_add_discovery_links' );  // discovery <link> tags
add_action( 'wp_head', 'wp_oembed_add_host_js' );          // signals wp-embed.js should load
add_action( 'rest_api_init', 'wp_oembed_register_route' ); // the /wp-json/oembed/1.0/embed route

The discovery links look like this in your head:

html
<link rel="alternate" type="application/json+oembed" href="https://example.com/wp-json/oembed/1.0/embed?url=..." />
<link rel="alternate" type="text/xml+oembed" href="https://example.com/wp-json/oembed/1.0/embed?url=...&amp;format=xml" />

The wp_oembed_add_host_js action is the signal that pulls in wp-includes/js/wp-embed.min.js in the footer (modern core checks, via wp_maybe_enqueue_oembed_host_js(), whether that action is still hooked before enqueueing the script, so removing the action is what stops the script). And every post gets an /embed/ URL that renders a standalone iframe-able version of itself, backed by its own rewrite rules.

How much does this save?

Be realistic. wp-embed.min.js is tiny, about 1.7 KB minified, and it loads in the footer, so it is not render-blocking and will not move your LCP. The discovery links are a few hundred bytes. As a pure page-speed change this is small, in the same league as the emoji and shortlink cleanups.

Where it earns its place is everything that is not page weight:

  • Attack surface and clutter. The oEmbed REST route is one more public endpoint. Removing the provider takes it (and the /embed/ URLs) off the table on a site that never needed them.
  • Crawlable /embed/ URLs. Every post has an /embed/ variant serving a near-duplicate iframe document. On a large site that is a lot of thin URLs a crawler can wander into. Killing the rewrite rules and redirecting them cleans that up, the same pattern as removing the feed URLs.
  • A genuinely unused feature gone. Fewer head lines, no footer script, no REST route, no embed endpoints, on the sites where the provider was pure overhead.

If you only care about the head, the two-line snippet at the top is enough. If you want the feature properly off, use the full version below.

The full must-use plugin

This disables the provider side completely and keeps the consumer side (your pasted YouTube/Twitter embeds) working. It is the well-established "disable embeds" approach, run late on init so it removes the core actions after they are registered:

A note on the difference from the popular Disable Embeds plugin by Pascal Birchler: that plugin also disables the consumer side, blocking embeds from any site not on core's whitelist and removing the Gutenberg embed block. This snippet deliberately does not, because the whole point here is to keep the consumer so your pasted YouTube, Twitter/X, and Vimeo embeds still render. If you genuinely want both halves gone, the Disable Embeds plugin is the heavier hammer; if you only want the provider off, this is the surgical version.

php
<?php
/**
 * Plugin Name: TE Disable oEmbed
 * Plugin URI:  https://techearl.com/disable-wordpress-oembed
 * Description: Turns off the oEmbed provider (discovery links, wp-embed.js, REST route, /embed/ URLs) while keeping pasted embeds working.
 * Version:     1.0.0
 * Author:      Ishan Karunaratne
 * Author URI:  https://techearl.com
 * License:     GPL-2.0-or-later
 * Text Domain: te-disable-oembed
 */

add_action( 'init', function () {
    // Stop the front-end output: discovery links and wp-embed.js.
    remove_action( 'wp_head', 'wp_oembed_add_discovery_links' );
    remove_action( 'wp_head', 'wp_oembed_add_host_js' );

    // WordPress 6.9+ also hooks the discovery links at priority 4; remove that too.
    remove_action( 'wp_head', 'wp_oembed_add_discovery_links', 4 );

    // WordPress 6.9+: the dedicated filter, the cleanest way to suppress the
    // discovery links regardless of priority. A harmless no-op on older cores.
    add_filter( 'oembed_discovery_links', '__return_empty_string' );

    // Remove the oEmbed REST route (the provider endpoint).
    remove_action( 'rest_api_init', 'wp_oembed_register_route' );

    // Turn off auto-discovery for unknown providers, but leave the known
    // ones (YouTube, Twitter/X, Vimeo, ...) intact so pasted embeds still work.
    add_filter( 'embed_oembed_discover', '__return_false' );

    // Drop the wpembed plugin from the classic editor's TinyMCE.
    add_filter( 'tiny_mce_plugins', function ( $plugins ) {
        return is_array( $plugins ) ? array_diff( $plugins, array( 'wpembed' ) ) : array();
    } );
}, 9999 );

// Remove the rewrite rules that create /embed/ URLs.
add_filter( 'rewrite_rules_array', function ( $rules ) {
    foreach ( $rules as $rule => $rewrite ) {
        if ( false !== strpos( $rewrite, 'embed=true' ) ) {
            unset( $rules[ $rule ] );
        }
    }
    return $rules;
} );

// 301-redirect any leftover /embed/ request back to the post itself.
function te_redirect_embed_urls() {
    $uri = $_SERVER['REQUEST_URI'];
    if ( is_404() && preg_match( '#/embed/?$#', $uri ) ) {
        $url = home_url( preg_replace( '#/embed/?$#', '/', $uri ) );
        wp_safe_redirect( esc_url_raw( $url ), 301 );
        exit;
    }
}
add_action( 'template_redirect', 'te_redirect_embed_urls' );

One thing this leaves alone on purpose: the block editor's embed block. The tiny_mce_plugins line only drops the wpembed plugin from the classic editor's TinyMCE; it does not touch Gutenberg. The block editor's Embed block (and the auto-embed you get when you paste a URL into a post) is the consumer side, so it keeps working exactly as before. Disabling the provider does not remove a single embedding feature you use to add a video to a post.

One note before the flush section below: the embed_oembed_discover filter set to false is the line that keeps your pasted embeds safe. It only disables auto-discovery for arbitrary URLs, while the providers WordPress already knows (YouTube, Vimeo, Twitter/X, and the rest of the built-in list) keep working because they do not rely on discovery. Removing the /embed/ rewrite rules, on the other hand, only takes effect once you flush, and there is more to that than the usual one-liner.

Flushing the rewrite rules (and the trap to avoid)

Removing a rule in the rewrite_rules_array filter is only half the job, because WordPress does not run that filter on every request. It builds the rule set once, stores it in the rewrite_rules row of wp_options, and matches incoming URLs against that stored copy. Until the stored copy is rebuilt, your /embed/ URLs keep resolving from the old rules, so you can remove the discovery links from the head and still find /sample-post/embed/ serving an iframe document.

Rebuilding the stored copy is called flushing. Do it once, by hand, after you add the code:

  • Dashboard: Settings, Permalinks, then click Save Changes. Saving the page alone reruns rule generation; you do not have to change a setting.
  • WP-CLI: wp rewrite flush.

Here is the part that bites, and it bites hardest in a performance context: never call flush_rewrite_rules() on a normal hook like init. It rewrites the entire rewrite_rules option, and running it on every page load drags the whole site down. It belongs on a plugin activation hook or a one-off manual flush, never in the request path.

That leaves a wrinkle, because I recommend shipping this as a must-use plugin, and must-use plugins have no activation hook. register_activation_hook() never fires for a file in mu-plugins/, since must-use plugins have no activate/deactivate lifecycle, so you cannot auto-flush the way a regular plugin would. For the must-use version, flush manually once (Settings, Permalinks, or wp rewrite flush) after dropping the file in.

If you want the flush automated, install it as a regular plugin instead and flush on activation:

php
// Only in a regular plugin, NOT in mu-plugins (no activation hook fires there).
register_activation_hook( __FILE__, 'flush_rewrite_rules' );
register_deactivation_hook( __FILE__, 'flush_rewrite_rules' );

The deactivation flush forces a clean rebuild when the plugin is turned off, so the /embed/ rules regenerate without your removal rather than leaving a stale set behind.

This is the one detail current guides get wrong, because core changed it. For years wp_oembed_add_discovery_links was hooked to wp_head at the default priority 10, so a plain remove_action( 'wp_head', 'wp_oembed_add_discovery_links' ) removed it cleanly.

As of WordPress 6.9, the function also runs at priority 4 (it was moved earlier in the head, with a priority-10 fallback retained). That means on a current install, the no-priority remove_action still catches the priority-10 registration, but a stray priority-4 copy can slip through.

WordPress 6.9 also ships a dedicated filter for exactly this, oembed_discovery_links, and per the core team it is the preferable way to suppress the links because it sidesteps the priority game entirely. Filter the output to an empty string and the discovery links never print, regardless of which priority they were hooked at:

php
// WordPress 6.9+: the clean, forward-compatible way. No remove_action needed.
add_filter( 'oembed_discovery_links', '__return_empty_string' );

That is the method to prefer going forward. If you need to support installs older than 6.9 (where the filter does not exist yet), the remove_action approach is the alternative; remove both priorities to be thorough:

php
remove_action( 'wp_head', 'wp_oembed_add_discovery_links' );     // priority 10 (all versions)
remove_action( 'wp_head', 'wp_oembed_add_discovery_links', 4 );  // WordPress 6.9+

The plugin above covers both bases: it adds the oembed_discovery_links filter (a no-op on pre-6.9, the cleanest method on 6.9+) and keeps the remove_action calls for older installs. If you only target the default priority and find a stray discovery link still printing on a 6.9+ site, the missing priority-4 removal is why.

The per-post embed cache, and when you have to refresh it

This is the step that catches people, and it is worth being precise about when it applies.

When your site consumes an embed (you paste a YouTube or Twitter URL into a post), WordPress does not re-fetch it on every view. It caches the rendered embed HTML against that specific post, in the postmeta table, under _oembed_{hash} keys (with a _oembed_time_{hash} companion holding the timestamp). The cache is per post, not global.

What that means for the changes in this article:

  • If you are only disabling the provider (the discovery links, wp-embed.js, the REST route, the /embed/ URLs), there is nothing to refresh. That output is printed live on every request, so removing the actions stops it immediately. The _oembed_ cache is on the consumer side and is not involved.
  • If you change how embeds are rendered or consumed (you replace embeds with plain links, drop a provider you used to embed, or otherwise alter the output of a consumed embed), then existing posts will keep showing the old cached HTML until that post's cache is cleared. The new behaviour applies to fresh embeds, but the already-cached posts look unchanged, which is exactly the "I changed the code and nothing happened" confusion.

There are two ways to force the refresh, and the cleaner one is the one you might not expect.

Re-save the affected post. WordPress core (WP_Embed) deletes a post's oEmbed caches automatically when the post is saved, and regenerates them on the next view. So opening a post and clicking Update, or re-saving it programmatically, refreshes that one post's embeds. This is the per-post answer, and it is why "just save the post again" works:

bash
# Re-save one post (regenerates its embed cache on next view).
wp post update 123 --post_status=publish

Clear the cached rows in bulk when you have changed embed handling site-wide and do not want to re-save every post by hand. WP-CLI is the cleanest:

bash
wp post meta delete --all --regex '_oembed_(time_)?[a-z0-9]{32}'

Or, as a one-off function you trigger once and then delete, scoped to just the oEmbed keys:

php
function te_clear_oembed_cache() {
    global $wpdb;
    return $wpdb->query(
        "DELETE FROM {$wpdb->postmeta}
         WHERE meta_key REGEXP '^_oembed_(time_)?[a-z0-9]{32}$'"
    );
}

Run it once, confirm the rows are gone, and remove the function. It is one-time housekeeping, never something to leave wired into every request. After clearing, the next view of each post rebuilds its cache using the current embed behaviour.

Verify it worked

Check the head no longer advertises oEmbed:

text
$ curl -s https://example.com/sample-post/ | grep -i oembed
(no output)

Confirm the REST route is gone (a disabled provider returns 404, not an embed payload):

text
$ curl -s -o /dev/null -w '%{http_code}\n' \
    'https://example.com/wp-json/oembed/1.0/embed?url=https://example.com/sample-post/'
404

And confirm a post's /embed/ URL now redirects instead of serving an iframe document:

text
$ curl -sI https://example.com/sample-post/embed/ | grep -i '^location\|^HTTP'
HTTP/2 301
location: https://example.com/sample-post/

Then, the important one: open a post that embeds a YouTube or Twitter URL and confirm the embed still renders. If it does, you have removed the provider without touching the consumer, which is exactly the goal. If pasted embeds break, you removed too much (usually the oembed_dataparse / pre_oembed_result filters that the consumer needs); back those out.

See also

Sources

Authoritative references this article was fact-checked against.

TagsWordPressPerformancePHPoEmbedwp-embed.jswp_headREST API

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts