GuidesOEMBED

YouTube oEmbed from JavaScript without CORS errors

YouTube's oEmbed endpoint sends CORS headers, so fetch('https://www.youtube.com/oembed?format=json&url=' + encodeURIComponent(videoUrl)) works from any page; on October 26, 2026 it echoed every origin we sent. A CORS error usually means the code fetched the watch page instead (no CORS headers, 1.46 MB) or a provider that sends none. Of 13 oEmbed providers we tested in Chromium, Firefox and WebKit, 9 answer the browser directly, Reddit, Flickr and Dailymotion need a proxy, and CodePen blocks every client.

Fetch YouTube oEmbed from the browser

Put the URL-encoded link in the url parameter of https://www.youtube.com/oembed?format=json. Watch URLs, youtu.be links, Shorts and playlists all work. The default iframe is 200 by 113 and maxwidth=640 alone only gives 356 by 200, so pass maxwidth and maxheight together. The 868-byte response has title, author_name, author_url, thumbnail_url (the 480 by 360 hqdefault.jpg) and html, a single iframe to youtube.com/embed/VIDEO_ID. Errors carry CORS headers too, so your code can read them: 400 Bad Request for an unknown video ID, 404 for a channel URL and 403 if you call the http:// endpoint.

getOEmbed(): direct for providers with CORS, proxy for the rest
// Runs in a module script or the DevTools console
const proxy = 'https://proxy.cors.dev/';

// cors: true means the endpoint sends CORS headers itself, so we skip the proxy
const providers = [
  { hosts: ['youtube.com', 'youtu.be'], endpoint: 'https://www.youtube.com/oembed', cors: true },
  { hosts: ['vimeo.com'], endpoint: 'https://vimeo.com/api/oembed.json', cors: true },
  { hosts: ['open.spotify.com'], endpoint: 'https://open.spotify.com/oembed', cors: true },
  { hosts: ['soundcloud.com'], endpoint: 'https://soundcloud.com/oembed', cors: true },
  { hosts: ['tiktok.com'], endpoint: 'https://www.tiktok.com/oembed', cors: true },
  { hosts: ['x.com', 'twitter.com'], endpoint: 'https://publish.x.com/oembed', cors: true },
  { hosts: ['bsky.app'], endpoint: 'https://embed.bsky.app/oembed', cors: true },
  { hosts: ['instagram.com'], endpoint: 'https://graph.facebook.com/v26.0/instagram_oembed', cors: true },
  { hosts: ['reddit.com'], endpoint: 'https://www.reddit.com/oembed', cors: false },
  { hosts: ['flickr.com', 'flic.kr'], endpoint: 'https://www.flickr.com/services/oembed/', cors: false },
  { hosts: ['dailymotion.com'], endpoint: 'https://www.dailymotion.com/services/oembed', cors: false },
];

function findProvider(pageUrl) {
  const { hostname } = new URL(pageUrl);
  return providers.find((p) =>
    p.hosts.some((host) => hostname === host || hostname.endsWith('.' + host)),
  );
}

async function getOEmbed(pageUrl, { maxwidth = 640, maxheight = 360 } = {}) {
  const provider = findProvider(pageUrl);
  if (!provider) throw new Error('No oEmbed provider for ' + pageUrl);

  const endpoint = new URL(provider.endpoint);
  endpoint.searchParams.set('url', pageUrl);
  endpoint.searchParams.set('format', 'json');
  endpoint.searchParams.set('maxwidth', maxwidth);
  endpoint.searchParams.set('maxheight', maxheight);

  const res = await fetch(provider.cors ? endpoint.href : proxy + endpoint.href, {
    credentials: 'omit',
  });
  if (!res.ok) {
    const reason = res.headers.get('X-Cors-Error') || `HTTP ${res.status}`;
    throw new Error(`oEmbed lookup failed for ${pageUrl}: ${reason}`);
  }
  return res.json();
}

const video = await getOEmbed('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
console.log(video.title, video.width, video.height, video.thumbnail_url);

In the map, cors: true marks the 8 endpoints that send CORS headers, so those are called directly; Reddit, Flickr and Dailymotion go through cors.dev. No provider asked for a key or a token.

Why you still get a CORS error

Five causes cover everything we saw:

  • You fetched the watch page. youtube.com/watch?v=... sends no CORS headers, so fetch() fails in all three engines. The page is 1,463,983 bytes, so the cors.dev free tier refuses it with response_too_large, or YouTube answers the proxy with a 429. Put the watch URL in the url parameter of the oEmbed endpoint instead.
  • The provider sends no CORS headers. Reddit, Flickr and Dailymotion answer 200 without Access-Control-Allow-Origin, so the browser hides the body. Send those three through a proxy.
  • A rate limit without CORS headers. TikTok sends Access-Control-Allow-Origin: * on success, but its 429 ratelimit triggered has none, so the browser shows a CORS error instead of a 429. Back off and retry later.
  • No-cors mode. mode: 'no-cors' returns an empty opaque response, so .json() fails. Remove it; here is why.
  • A blocked request. net::ERR_BLOCKED_BY_CLIENT in DevTools means an ad blocker or privacy extension stopped the request, which is not CORS. Catch the rejection and fall back to a plain link.

oEmbed providers tested for CORS

We called each endpoint with fetch() from http://localhost and http://127.0.0.1 pages in all three engines, and with curl from a datacenter IP, a US residential IP and the cors.dev free tier. "CORS direct" is what the endpoint sends to the browser; the next column is the result through cors.dev with no key.

oEmbed endpoints tested with fetch() in Chromium, Firefox and WebKit, checked October 2026
ProvideroEmbed endpointCORS directVia cors.dev free tierAuth
YouTubewww.youtube.com/oembedYes, echoes your originWorksNone
Vimeovimeo.com/api/oembed.jsonYes, any originWorksNone
Spotifyopen.spotify.com/oembedYes, any originWorksNone
SoundCloudsoundcloud.com/oembedYes, any originWorksNone
TikTokwww.tiktok.com/oembedYes, but its 429 has noneFails, 502 upstream_unreachableNone
X (Twitter)publish.x.com/oembedYes, echoes your originWorksNone
Blueskyembed.bsky.app/oembedYes, any originWorksNone
Instagramgraph.facebook.com/v26.0/instagram_oembedYes, any originWorksNone since June 15, 2026
Facebook postsgraph.facebook.com/v26.0/oembed_postYes, any originFails, 400 Invalid parameterNone since June 15, 2026
Redditwww.reddit.com/oembedNoWorksNone
Flickrwww.flickr.com/services/oembedNo, JSONP onlyWorksNone
Dailymotionwww.dailymotion.com/services/oembedNoWorksNone
CodePencodepen.io/api/oembedNo, Cloudflare challengeFails, 403None

The official registry at oembed.com/providers.json lists 394 providers and sends Access-Control-Allow-Origin: * itself, but its Instagram and Facebook entries still point at v16.0. publish.twitter.com/oembed, the host most tutorials use, answers with a 301 to publish.x.com/oembed; both send CORS headers, so browsers follow the redirect.

Instagram and Facebook oEmbed need no token now

Meta's Graph API reference says: "On June 15, 2026, we lifted the access token requirement from all Meta oEmbed APIs." That covers Threads, Instagram, Facebook post and Facebook video oEmbed, and App Review is no longer needed for public content. Both graph.facebook.com/v26.0/instagram_oembed and oembed_post sent Access-Control-Allow-Origin: * and answered 200 with no token in our test. Without a token you get 1,000 requests per endpoint per hour; apps approved for Meta oEmbed Read can still send a token for 5 million per endpoint per day. Tutorials that put an app access token in client-side code are out of date, and that token is a secret that never belongs in frontend code.

The response is thin: author_name, author_url and the thumbnail fields are gone (fully deprecated November 3, 2025), so you get type, width, html and the provider fields. Meta allows the endpoint only for embedding, not for analytics, and a missing post returns 400 with code 24 (Media Not Found). Call Meta directly: oembed_post returned 400 (#100) Invalid parameter through cors.dev.

oEmbed discovery from any URL

For sites outside your map, the oEmbed spec lets a page advertise its endpoint in a link rel="alternate" type="application/json+oembed" tag. Page HTML usually has no CORS headers, so fetch it through a proxy and parse it with DOMParser; if there is no tag, fall back to Open Graph link previews. Real pages bend the spec: Flickr writes rel="alternative", SoundCloud uses text/json+oembed and Bluesky puts an at:// URI in the endpoint's url parameter.

Find the oEmbed endpoint in any page's HTML
// Runs in a module script or the DevTools console
const proxy = 'https://proxy.cors.dev/';

async function readJson(url) {
  const res = await fetch(url, { credentials: 'omit' });
  if (!res.ok) throw new Error(res.headers.get('X-Cors-Error') || `HTTP ${res.status}`);
  return res.json();
}

async function discoverOEmbed(pageUrl) {
  const page = await fetch(proxy + pageUrl, { credentials: 'omit' });
  if (!page.ok) {
    throw new Error(`Page fetch failed: ${page.headers.get('X-Cors-Error') || page.status}`);
  }

  // DOMParser builds an inert document: no scripts run and no images load
  const doc = new DOMParser().parseFromString(await page.text(), 'text/html');
  const link = [...doc.querySelectorAll('link[rel~="alternate"], link[rel~="alternative"]')].find(
    (el) => /json\+oembed/i.test(el.getAttribute('type') || ''),
  );
  if (!link) return null;

  // https pages and the proxy can't fetch http, so we upgrade the endpoint
  const endpoint = new URL(link.getAttribute('href'), pageUrl);
  endpoint.protocol = 'https:';

  // Many endpoints send CORS headers, so the proxy is only the fallback
  try {
    return await readJson(endpoint.href);
  } catch {
    return readJson(proxy + endpoint.href);
  }
}

const post = await discoverOEmbed('https://wordpress.org/news/2024/11/rollins/');
console.log(post?.provider_name, post?.title, post?.type);

Through cors.dev this worked in all three engines for a WordPress.org post, a Bluesky post, a Flickr photo page (522,578 bytes), a SoundCloud track and a Spotify track. It failed for the YouTube watch page, which is over the 1 MiB free limit, so check your provider map first and use discovery for the rest.

Render the html field safely

The html field is markup written by the provider. The oEmbed spec calls it "a vector for XSS attacks from the provider" and recommends showing it in an iframe hosted on another domain. Discovery makes this worse: the owner of whatever page your user pasted picks the endpoint and writes the html.

In our test, innerHTML did not run script tags in Chromium, Firefox or WebKit, which is why X, Instagram, Bluesky and Reddit embeds show up as plain blockquotes: their widget script never loads. It did run an img onerror handler, with your page's privileges, and createContextualFragment() ran the script tag outright.

Render the embed from an iframe allowlist, never raw html
// Runs in a module script; uses getOEmbed() from the first snippet
const iframeHosts = new Set([
  'www.youtube.com',
  'player.vimeo.com',
  'open.spotify.com',
  'w.soundcloud.com',
  'www.tiktok.com',
  'geo.dailymotion.com',
]);

function renderEmbed(pageUrl, data, container) {
  // Parse the provider's html without running it and keep only an iframe from a known host
  const parsed = new DOMParser().parseFromString(data.html || '', 'text/html');
  const src = parsed.querySelector('iframe')?.getAttribute('src');
  const frameUrl = src ? new URL(src, pageUrl) : null;

  if (frameUrl?.protocol === 'https:' && iframeHosts.has(frameUrl.hostname)) {
    const frame = document.createElement('iframe');
    frame.src = frameUrl.href;
    frame.width = data.width || 640;
    frame.height = data.height || 360;
    frame.title = data.title || data.provider_name || 'Embedded media';
    frame.allow = 'autoplay; encrypted-media; picture-in-picture; fullscreen';
    frame.referrerPolicy = 'strict-origin-when-cross-origin';
    frame.loading = 'lazy';
    container.replaceChildren(frame);
    return;
  }

  // Script-based embeds (X, Bluesky, Instagram, Reddit) become a plain link card
  const target = new URL(pageUrl);
  if (target.protocol !== 'https:') throw new Error('Only https links are rendered');

  const link = document.createElement('a');
  link.href = target.href;
  link.rel = 'noopener';
  link.textContent = data.title || data.author_name || data.provider_name || target.hostname;
  if (data.thumbnail_url?.startsWith('https://')) {
    const img = document.createElement('img');
    img.src = data.thumbnail_url;
    img.alt = '';
    img.loading = 'lazy';
    link.prepend(img);
  }
  container.replaceChildren(link);
}

const pageUrl = 'https://vimeo.com/22439234';
renderEmbed(pageUrl, await getOEmbed(pageUrl), document.body);

The code parses html with DOMParser, which runs nothing, keeps an iframe only when its host is on a 6-host allowlist, creates a fresh iframe itself and turns everything else into a link card built with textContent. Across 11 providers it produced 6 iframes and 5 link cards and inserted 0 script elements. Text fields are untrusted too: Flickr's author_name for the spec's own example photo is wrapped in Unicode bidi control characters. For the full X or Instagram card, request the html without its script (omit_script=1 on X, omitscript=true on Instagram), sanitize the blockquote and load the provider's widget script from a fixed URL in your own code.

Limits to plan for

  • Meta: 1,000 requests per endpoint per hour without a token, 5 million per day with an approved app.
  • TikTok: its 429 has no CORS headers, and it failed through cors.dev with 502 upstream_unreachable in 9 of 9 tries, so call it from the user's browser.
  • Facebook posts: oembed_post returned 400 through cors.dev in 4 of 4 tries; it sends CORS headers, so call it directly.
  • CodePen: codepen.io/api/oembed returned a Cloudflare challenge (403) to a datacenter IP, a residential IP and cors.dev.
  • cors.dev free tier: no key or signup, GET and HEAD over HTTPS, 1 MiB per response, 10 seconds, 600 requests per minute per IP, cookies never forwarded.
  • cors.dev Pro: 6 MiB responses and 500,000 requests for $5 a month, enough for large pages during discovery; see pricing.

Good questions.

Does the YouTube oEmbed endpoint support CORS?

Yes. https://www.youtube.com/oembed echoes the request's Origin in Access-Control-Allow-Origin, so fetch() works from any site, localhost or 127.0.0.1. For a channel's latest videos without an API key, use its YouTube RSS feed.

Why do I get a CORS error when I fetch a YouTube video page?

The watch page sends no Access-Control-Allow-Origin header and is 1.46 MB of HTML. Pass the watch URL in the url parameter of https://www.youtube.com/oembed?format=json instead.

Do I need an access token for Instagram oEmbed?

No. Since June 15, 2026, Meta's oEmbed endpoints work without a token, up to 1,000 requests per endpoint per hour. Call https://graph.facebook.com/v26.0/instagram_oembed?url=... directly from the browser.

Is it safe to insert the oEmbed html with innerHTML?

No. innerHTML skips script tags but runs event-handler attributes such as onerror, and the spec itself calls the html field an XSS vector. Parse it with DOMParser, keep only allowlisted iframes and render text with textContent.

Does the X oEmbed API support JSONP?

Yes: add callback=NAME and publish.x.com/oembed wraps the JSON in a call to NAME; Flickr does the same with jsoncallback. X also sends CORS headers, so plain fetch() is simpler; see JSONP vs CORS.

How do I get a YouTube thumbnail from oEmbed?

Read thumbnail_url: a 480 by 360 JPEG at https://i.ytimg.com/vi/VIDEO_ID/hqdefault.jpg. To control playback after embedding, use the YouTube IFrame Player API.