# Every Post You Publish Has a Second URL: A WordPress Embeds and oEmbed Guide

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.

**WHY IT MATTERS**

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.

**WHAT THE oEMBED RESPONSE EXPOSES**

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.

**THE HONEST RECOMMENDATION**

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

Will Google index the /embed/ version of my posts as duplicate content?It is unlikely. Since WordPress 5.7, core adds a noindex directive to embed views by default through the wp\_robots filter, and the embed template also outputs a canonical tag pointing at the real post. A noindex directive is a request that compliant crawlers typically honour rather than an enforced block, and the page still returns a 200 response to anything that asks for it, but this particular concern has been handled in core for several years.

Does the oEmbed endpoint expose my WordPress login name?It exposes the post author's display name and their author archive URL, not the password-protected login credential. On sites where the display name and the author slug were never changed, both of those values commonly match the username, so the login name is effectively revealed. Setting a distinct display name and changing the user nicename so the author slug no longer matches the login closes that gap for this endpoint and for author archives at the same time.

Why does my post show as a plain link instead of a preview card when someone embeds it?The most common cause is a framing restriction on your site. WordPress core sends an X-Frame-Options header, along with a Content Security Policy frame-ancestors directive limited to self, only on its login and admin screens and never on front-end requests. Many hosts, security plugins and CDN configurations apply the same restriction across the whole site. When that happens the browser refuses to render your embed page inside another site's iframe, the iframe never completes its handshake, and the fallback blockquote link stays visible instead.

What is the supported way to stop my posts being embeddable?WordPress 6.8 added an embeddable argument to post type registration, which defaults to the value of the public argument. Setting it to false for a post type is the supported way to opt out, and the same release added a filter that lets you make the decision for an individual post instead. Core checks that value before it builds an oEmbed response, so the feature is switched off at the source rather than having its symptoms hidden by removing hooks.

Do the \_oembed\_ rows in my postmeta table get cleaned up automatically?Not on a schedule. The cached value has a time to live governed by the oembed\_ttl filter, which has defaulted to one day since WordPress 4.0, but expiring simply means the next request fetches a fresh copy and overwrites it. Each distinct combination of embed URL and shortcode attributes creates its own key, so editing embeds over the years leaves older rows behind. Core also stores some results in a hidden post type called oembed\_cache, which does not appear anywhere in the admin interface.

Why does pasting some URLs into the editor produce a plain link instead of an embed?WordPress ships with a built-in list of trusted oEmbed providers, which in current core is 59 URL patterns resolving to 36 distinct provider endpoints. A URL outside that list is not rejected outright: core still attempts to discover an oEmbed endpoint on the page itself, and treats anything it gets back as untrusted, sanitising it down to a link, a blockquote and a sandboxed iframe. A plain link is what you get when that discovery finds no endpoint at all, which is a different outcome from the source simply not being on the list.

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](https://amplifi.studio) — see also [Your Admin Username Is Hiding in Plain Sight: A WordPress Author Archives Guide](https://amplifi.studio/wordpress-author-archives-guide/).