We were auditing a site a while back and pulled a list of every URL it actually answered on. The site had a few hundred posts and pages. The list came back roughly twice as long as we expected, and nearly all of the surplus ended the same way: /embed/.
Nobody had built those. Nobody knew they existed. They’d been there since the day the site launched, because WordPress has quietly published a second URL for every post you’ve ever written since version 4.4, and almost nobody has ever opened one.
It’s not a hack and it’s not a bug. It’s the oEmbed system, and it’s the reason you can paste a YouTube link into the editor and get a video instead of a link. What most people don’t realise is that the feature runs in both directions: your site consumes embeds, and it also hands out embeds of itself to anyone who asks. This guide covers what’s on that second URL, what it exposes, the one header that silently breaks it, and how to switch it off properly if you don’t want it.
What WordPress Actually Publishes at /embed/
Take any published post on your site, add embed/ to the end of its permalink, and load it. You’ll get a real HTML page: your site title and icon, the post title as a link, an excerpt, the featured image, a comment count, and a share button that hands over a copy-paste iframe snippet.
That page is designed to be viewed inside an iframe on somebody else’s website: a small self-contained preview card, styled by WordPress rather than by your theme, part of core since WordPress 4.4. The rewrite rule that creates it is generated for every permalink structure core builds, which is why you get one per post without ever opting in.
The JSON endpoint sitting behind it
The /embed/ page isn’t the whole system. There’s a REST route underneath it at /wp-json/oembed/1.0/embed, and that’s the one other software actually talks to. Pass it a URL from your own site and it returns a JSON object describing the post.
curl -sS "https://example.com/wp-json/oembed/1.0/embed?url=https%3A%2F%2Fexample.com%2Fyour-post%2F"
What comes back is a standard oEmbed response: a version marker, your site name and home URL as the provider, the post title, a type, a width and height, the ready-made iframe HTML, and a thumbnail URL with its dimensions when the post has a featured image. Core builds that payload in get_oembed_response_data(), and it clamps the width between 200 and 600 pixels by default before working out a 16:9 height with a 200 pixel floor. Some SEO plugins hook the oembed_response_data filter and add fields of their own — a description is a common one — so what your site returns may be slightly richer than stock core.
How the endpoint and the page fit together
When another WordPress site pastes your link into its editor, it finds that JSON route through a discovery link in your page head, calls it, and drops the returned iframe into the post — and that iframe points back at your /embed/ page. The JSON endpoint is the handshake, the /embed/ page is the payload, and you’re serving both on every public post right now.
There’s a nice piece of engineering in the returned markup that’s worth knowing about. The HTML contains both an iframe and a blockquote holding a plain link. Core generates a random ten-character secret, stamps it on both elements and on the iframe URL fragment, and the small wp-embed script on the embedding page uses it to match up postMessage traffic. The blockquote only gets hidden once the iframe has messaged back with a matching secret. Remember that detail, because it’s about to matter.
Most WordPress advice treats embeds as a feature you use, not a surface you expose. But the endpoint answers to anyone, it describes your content in a machine-readable format, and it names your post author in a field that has nothing to do with your theme. If you’ve ever hardened your site and assumed you’d covered every public route, this is the one that usually got missed.
Start By Ruling Out the Thing It Isn’t
The obvious worry, when you find a few hundred URLs you didn’t know about, is duplicate content — every post now has a twin URL carrying the title, the excerpt and the featured image, which looks like exactly the kind of thin page that clogs up a crawl budget. Core already handled it. Since WordPress 5.7 there’s a function called wp_robots_noindex_embeds() wired into the wp_robots filter by default, and it adds a noindex directive to any request where is_embed() is true. Load one of your own embed URLs and read the head: you’ll find a robots meta tag asking search engines not to index it, and a canonical tag pointing back at the real post.
Worth being precise about what that buys you. A robots meta tag is a request that compliant crawlers typically honour, not a lock, and the page still returns a 200 to anything that asks. But as far as the mainstream search engines go, this specific worry has been handled in core for years, and rewriting your robots.txt to chase it is effort spent on a solved problem. The real issues are elsewhere.
What the Endpoint Actually Hands Out
Look again at that JSON response, specifically at two fields nobody expects to find there: author_name and author_url. Core populates them from the post author’s display name and their author archive URL.
That’s a genuinely awkward interaction if you’ve done any author-archive hardening. The standard advice for stopping username enumeration is to change the user’s nicename so the author archive slug stops matching the login, set a display name that isn’t the username, and noindex or disable the author archives. Do all of that and the front end looks clean. The oEmbed endpoint keeps answering anyway, with the display name in one field and the author archive URL in the next, in tidy parseable JSON, to anyone who sends a request.
Why this isn’t quite the same as the REST users endpoint
It’s tempting to file this next to the well-known /wp-json/wp/v2/users enumeration issue and move on, but they behave differently and the distinction matters when you’re deciding what to do. The users route lists every author on the site in one call, which is what makes it useful to an attacker doing reconnaissance. The oEmbed route is per-post: you have to already know a post URL, and you get back the one author attached to it.
So it’s a narrower leak, and it’s a leak of the display name and archive URL rather than the login itself. If your display name and your nicename are already distinct from your username, the exposure here is mostly informational. If you never changed them, this is a second door onto the same information, and locking the first one didn’t lock this one.
For one post URL you already know, an unauthenticated request returns your site name and home URL, the post title, the author’s display name, the author archive URL, the featured image URL with its dimensions, and a block of iframe HTML. Nothing here is private data — but it’s a structured, scrapeable description of a post, served without a key, from a route most site owners have never opened.
The Header That Quietly Breaks Your Own Embeds
This is the failure that costs people real reach, and it comes from a well-intentioned security change. WordPress core sends an X-Frame-Options: SAMEORIGIN header — alongside a Content Security Policy of frame-ancestors 'self' — on its login screen and its admin screens, and on no front-end request at all. Those are the pages that need clickjacking protection. A lot of hosts, security plugins and CDN rule sets take the same headers and apply them to the entire site instead, because site-wide is the safer-sounding default.
Now walk through what that does to the feature we’ve just described. Someone embeds your post, their browser tries to load your /embed/ page in an iframe, your server replies with a header saying this page may only be framed by its own origin, and the browser refuses to render it. The iframe never loads, so it never sends the postMessage handshake back, so the secret never matches, so the fallback blockquote is never hidden. What their reader sees is a bare text link where a preview card was supposed to be.
Nothing errors. Nothing logs. Your site looks fine to you, because you never load your own posts inside somebody else’s frame. You just quietly stop getting the nicer-looking version of every share, on every WordPress site that ever links to you.
Checking your own headers
One request settles it. Ask for the headers on a normal post and look for the framing directives:
curl -sS -I "https://example.com/your-post/" | grep -i -E 'x-frame-options|content-security-policy'
An empty result means nothing is blocking framing and your embeds work as designed. A SAMEORIGIN value, or a frame-ancestors 'self' directive in a Content Security Policy, means cross-site embeds of your posts are being refused. Neither of those is wrong, exactly — it’s a real trade-off between a hardening default and a distribution feature, and plenty of sites should keep the header. Just make it a decision rather than a surprise.
Turning It Off, the Way That Actually Works
Search for how to disable WordPress embeds and you’ll find the same snippet copied across a decade of blog posts: a stack of remove_action calls unhooking the discovery links, dequeuing the script, and filtering the rewrite rules. Those snippets mostly still work, but they’re fighting the framework rather than using it, and they tend to rot — core moved the oEmbed discovery links to an earlier priority in WordPress 6.9 and had to ship a back-compatibility shim specifically so old removal snippets wouldn’t break.
The modern way: one argument
WordPress 6.8 added an embeddable argument to post type registration. It defaults to whatever public is set to, which is why every public post type is embeddable without anyone choosing that. Setting it to false is now the supported way to opt a post type out.
add_filter( 'register_post_type_args', function ( $args, $post_type ) {
if ( 'post' === $post_type ) {
$args['embeddable'] = false;
}
return $args;
}, 10, 2 );
The same release added an is_post_embeddable filter, so you can make the call per post rather than per type — useful when you want members-only content excluded but everything else still shareable.
add_filter( 'is_post_embeddable', function ( $embeddable, $post ) {
return has_term( 'members-only', 'category', $post ) ? false : $embeddable;
}, 10, 2 );
Either way, get_oembed_response_data() checks is_post_embeddable() before it builds a response and bails out if the answer is no. You’re switching the feature off at the source instead of hiding its symptoms.
Decide what you’re actually trying to stop
Before you disable anything, it’s worth separating the two directions, because people routinely turn off the wrong one. Stopping your site from being embedded elsewhere is the embeddable setting above. Stopping your site from embedding other people’s content is a completely different mechanism, and turning off one has no effect on the other. If your goal is a lighter page rather than a smaller footprint, note that since WordPress 5.9 the wp-embed script is only enqueued on pages that actually contain a post embed, so on most sites it isn’t loading anyway.
For most sites, leave embeds on. They’re a free distribution channel, core already noindexes the pages, and once your display name and author slug are set sensibly there’s nothing left in the response you weren’t already publishing. Fix your display name so the author field is boring, and check whether a framing header is silently cancelling the feature. Reach for the embeddable argument only when you have a specific reason — a members area, an intranet, a client site under NDA — rather than as routine hardening.
The Cache Nobody Ever Cleans
The other direction of the system leaves a mess in your database, and it’s the part that shows up in a site audit years later. Every time your site fetches an embed from an external provider, the resulting HTML gets cached so the request doesn’t repeat on every page load. Core stores it as post meta on the post containing the embed, under keys prefixed with _oembed_, paired with a timestamp key. The lifetime is governed by the oembed_ttl filter, which has defaulted to one day since WordPress 4.0.
The subtlety is that expiry and deletion aren’t the same thing. When the cached copy goes stale, core fetches a fresh one and overwrites it. The rows don’t get cleaned up on a schedule, and each distinct combination of URL and shortcode attributes produces its own key. Edit a post, change an embed’s width, swap the video: you get another row, and the old one stays. On a site with a long history of embedded media this accumulates into real weight in wp_postmeta, which is one of the tables everything else joins against.
There’s a second store too. When core can’t attribute a cached embed to a specific post, it writes the result into a hidden post type called oembed_cache instead. Those rows sit in wp_posts, invisible in the admin because the post type isn’t shown in the UI.
Counting both is quick with WP-CLI:
wp db query "SELECT COUNT(*) FROM $(wp db prefix --url=https://example.com)postmeta WHERE meta_key LIKE '_oembed_%'"
wp post list --post_type=oembed_cache --format=count
A few dozen rows is normal and not worth touching. Thousands on a site that embeds very little is a sign something has been regenerating cache keys in a loop, and that’s worth investigating before you delete anything. If you do clear them, take a database backup first — these are ordinary rows and nothing will stop you removing more than you meant to.
A Five-Minute Audit of Your Own Site
You can check all of this faster than you can read about it. Start with the embed page itself and confirm it’s noindexed:
curl -sS "https://example.com/your-post/embed/" | grep -i -E 'name=.robots|rel=.canonical'
Then look at what the JSON endpoint says about your author. This is the field most likely to surprise you, because it doesn’t come from anything you’ve configured on the front end:
curl -sS "https://example.com/wp-json/oembed/1.0/embed?url=https%3A%2F%2Fexample.com%2Fyour-post%2F" | grep -o '"author_[a-z]*":"[^"]*"'
If the name that comes back is your login, fix it in your user profile — set a display name, and change the nicename so the author slug stops matching too. Then run the header check from earlier to see whether cross-site embedding is being refused, and count your cache rows. Four checks, and you know more about this corner of your site than most people ever will.
One last thing worth knowing while you’re in here: the consuming side ships with a fixed list of trusted providers, currently 59 URL patterns resolving to 36 distinct oEmbed endpoints. A URL outside that list isn’t rejected — core still tries to discover an oEmbed endpoint on the page itself, and only treats whatever comes back as untrusted, running it through wp_kses and stripping it to a link, a blockquote and a sandboxed iframe. You get a plain link when that discovery finds nothing, which is a different outcome from simply not being on the list.
Frequently Asked Questions
If you’d rather someone else ran the audit, we do this on every WordPress site we take on — the routes nobody opens are usually where the interesting findings are.
Built by amplifi.studio — see also Your Admin Username Is Hiding in Plain Sight: A WordPress Author Archives Guide.