Skip to content
pbb.io/blog

Embedding YouTube without leaking your readers' IPs

Dropping the iframe only gets you halfway, because the thumbnail still phones home. What it takes to serve YouTube preview images from your own domain.

July 31, 2026
6 min read
Elixir Phoenix Privacy

My blog auto-embeds YouTube links. Drop a youtu.be/... link in a post and the markdown converter turns it into a clickable card with the video thumbnail behind it. No iframe, no YouTube JS, because I don’t want Google’s player on my pages.

Dropping the iframe only gets you halfway, though. The card needs a thumbnail, and the obvious way to get one is this:

style="background-image: url(https://i.ytimg.com/vi_webp/#{video_id}/hqdefault.webp)"

That’s still a request to Google from the reader’s browser, carrying their IP, User-Agent, and Referer, before they’ve clicked anything. A no-embed embed that isn’t private is just a slower embed.

So the thumbnails go through my own domain instead:

def thumbnail_path(video_id), do: "/images/youtube/#{video_id}.webp"

The reader’s browser only ever talks to me. The only IP Google sees is the server’s.

That URL is a guess, too, and picking the boring size pays off here. maxresdefault only exists for HD uploads, and plenty of older videos never got an sddefault either. hqdefault is the one size every video has, which makes it the right default for a card that gets cropped to aspect-video anyway. The webp variant isn’t quite universal, so the jpeg sits behind it as a fallback:

@thumbnail_variants [{"vi_webp", "hqdefault.webp"}, {"vi", "hqdefault.jpg"}]
def thumbnail_urls(video_id) do
Enum.map(@thumbnail_variants, fn {dir, file} -> "#{@thumbnail_host}/#{dir}/#{video_id}/#{file}" end)
end

The route

Phoenix routers let you skip the pipeline entirely, which is what you want here. No accepts ["html"] gambling on whatever Accept header a browser sends for images, no session, no CSRF token on a request for a JPEG:

scope "/images", WebsiteWeb do
get "/youtube/:video_id", YoutubeController, :thumbnail
end

Fetching, carefully

A route that takes a path segment and appends it to an upstream URL will happily fetch whatever you point it at. So the video ID gets checked against YouTube’s actual alphabet before it goes anywhere near a URL, and a bad one never leaves the box:

@video_id_regex ~r/\A[A-Za-z0-9_-]{1,32}\z/
def valid_video_id?(video_id) when is_binary(video_id), do: Regex.match?(@video_id_regex, video_id)
def valid_video_id?(_video_id), do: false
def fetch_thumbnail(video_id) do
if valid_video_id?(video_id) do
video_id |> thumbnail_urls() |> fetch_first()
else
{:error, :invalid_video_id}
end
end

Walking that order is one clause, and the guard is the interesting bit. Only a genuinely missing variant earns another round trip; a refused connection or a garbage response gives up immediately instead of hammering YouTube again for the same failure:

defp fetch_first([url | fallbacks]) do
response = url |> Req.get(@req_options) |> handle_response()
case response do
{:error, {:unexpected_status, 404}} when fallbacks != [] -> fetch_first(fallbacks)
result -> result
end
end

There’s no fetch_first([]) clause, because there doesn’t need to be one. On the last URL fallbacks is empty, the guard fails, and the error falls through as the result.

Worth noting Plug.Static already refuses paths containing .. outright. Since /images is in my static_paths, it sees these requests first and rejects the classic traversal attempts before my code runs. Nice, but not something to rely on.

The response needs the same suspicion. “Trust ytimg to only ever return images” is not a security property, so the content type has to actually start with image/, and a surprise multi-megabyte body gets dropped rather than relayed. Pattern matching makes the whole policy readable top to bottom:

defp handle_response({:ok, %Req.Response{status: 200, body: body} = response})
when byte_size(body) <= @max_bytes do
case content_type(response) do
"image/" <> _subtype = content_type -> {:ok, content_type, body}
other -> {:error, {:unexpected_content_type, other}}
end
end
defp handle_response({:ok, %Req.Response{status: 200}}), do: {:error, :too_large}
defp handle_response({:ok, %Req.Response{status: status}}), do: {:error, {:unexpected_status, status}}
defp handle_response({:error, reason}), do: {:error, reason}

The size cap living in a guard is my favourite part. An oversized body simply doesn’t match the first clause and falls through to :too_large on the next one, with no branching inside the function body.

Passing it through

The controller is the boring half, which is how it should be. Strip the extension, fetch, hand back the bytes with the content type the upstream actually sent:

def thumbnail(conn, %{"video_id" => video_id}) do
thumbnail = video_id |> String.replace_suffix(".webp", "") |> Youtube.fetch_thumbnail()
case thumbnail do
{:ok, content_type, body} ->
conn
|> put_resp_content_type(content_type, nil)
|> put_resp_header("cache-control", "public, max-age=86400")
|> send_resp(200, body)
{:error, _reason} ->
conn
|> put_resp_header("cache-control", "public, max-age=300")
|> send_resp(404, "")
end
end

Every failure collapses into the same 404. A missing thumbnail is cosmetic, so it fails fast and cheap, which is also why the request goes out with retry: false:

@req_options [
connect_options: [timeout: @timeout],
decode_body: false,
receive_timeout: @timeout,
retry: false
]

Req retries transient failures three times with backoff by default. Sensible for an API client, wrong for a background image. A blank card beats a page that hangs for seven seconds while an HTTP client dutifully backs off and tries again.

The day of Cache-Control is what keeps this from being one outbound request per pageview. There’s no server-side cache yet, so each cold browser cache costs one fetch to Google. For a handful of videos that’s fine, and if it stops being fine, an ETS cache or a CDN solves it without touching any of the above.

The rest is unglamorous. One route, one regex, and the reader’s browser never talks to Google.

Which deserves a demo, and there is only one correct choice of video:

Open your network tab. That background came from this domain.

Back to posts

Elixir Phoenix Privacy
Phil-Bastian Berndt

Phil-Bastian Berndt
Tech Lead at Naymspace