Dev.to Security 🔐 Cybersecurity 👁 0 📖 9 min read

Mixed Content After Moving a Blog to HTTPS: Which http:// Images Still Load

Once a blog is on HTTPS, every image, script, stylesheet and iframe its pages still load over http:// is mixed content, and current browsers no longer handle it one way. A plain <img src="http://..."> is rewritten to htt

Once a blog is on HTTPS, every image, script, stylesheet and iframe its pages still load over http:// is mixed content, and current browsers no longer handle it one way. A plain <img src="http://..."> is rewritten to https:// and loads, but only if the image host answers on HTTPS; there is no fallback to the old address. Scripts, stylesheets and iframes are blocked. Responsive images are blocked too: any http:// URL inside srcset or a <picture> source stops the image from rendering, even when the same tag's src is already https://.

That last case is easy to miss after a migration, because a find-and-replace aimed at src="http:// or a quick look at the post's main image catches the src and leaves the srcset. Below is what I measured in Chromium, why the spec treats responsive images differently, a script that lists every http:// subresource on a live blog, and the order I'd fix them in.

What Chromium 141 did with each http:// reference

On October 15, 2026 I ran a headless Chromium 141.0.7390.37 (through Playwright 1.56.1) against a test page served over HTTPS. The page pulled resources from two local hosts on the standard ports: one that served every file over both HTTP and HTTPS, and one that only spoke HTTP, the way an old personal image server or a lapsed CDN might. The server log recorded which scheme each request actually arrived on, and the page reported whether each image decoded. I ran the same page a second time with an upgrade-insecure-requests meta tag in the head.

Reference in the HTML Default behavior With upgrade-insecure-requests
<img src="http://...">, host also serves HTTPS Upgraded to https, loads Upgraded, loads
<img src="http://...">, host is HTTP only Upgraded, connection refused, broken image Same: broken image
<img srcset="http://... 1x"> Blocked Upgraded, loads
<img src="https://..." srcset="http://... 300w, http://... 1024w"> Blocked; the browser picked a srcset candidate and never used the https src Upgraded, loads
<picture><source srcset="http://..."> Blocked; the fallback <img> was not used Upgraded, loads
Inline style="background-image:url(http://...)" Upgraded, loads Upgraded, loads
<link rel="stylesheet" href="http://..."> Blocked Upgraded, applied
<script src="http://..."> Blocked Upgraded, ran
<iframe src="http://..."> Blocked Upgraded, loads

The console messages match the two outcomes. An upgraded image logs a warning ending in "This request was automatically upgraded to HTTPS", and a blocked one logs an error ending in "This request has been blocked; the content must be served over HTTPS." If you open DevTools on a migrated post and see only warnings, the page is relying on the upgrade, and each of those images depends on its host keeping HTTPS working.

Why a responsive image breaks when its plain src would have loaded

The automatic upgrade comes from the W3C Mixed Content Level 2 draft. It upgrades requests whose destination is an image, audio or video, then lists an exception: a request whose destination is "image" and whose initiator is "imageset" is left alone, and the spec gives the reason as "for historical reasons". Requests that aren't upgraded are blocked. MDN's mixed content reference spells out the practical meaning: an <img> set through src is upgradable, while one set "via srcset or <picture>" is blockable.

All three engines now apply the upgrade to plain images. Chrome announced the plan in 2019, and its update on that post says mixed images are "autoupgraded and blocked if they fail to load over https://". Firefox followed in version 127, where images, audio and video "are not loaded if they are unavailable via HTTPS". WebKit's Safari 18.0 notes say images and media "will now be auto-upgraded to HTTPS, in adherence with Mixed Content Level 2". I only ran Chromium, so the srcset result above is measured in the engine behind Chrome and Edge, and for Firefox and Safari it follows from the spec rather than from my test.

The practical consequence is that the scheme in srcset decides the image. A browser that chooses a candidate from srcset doesn't go back to src when that candidate is blocked, which is why the row with an https src still rendered as a broken image.

List every http:// subresource on the live blog

Checking posts one at a time in DevTools doesn't scale to an archive of a few hundred. The script below reads your sitemap, parses each page, and prints every http:// subresource with the outcome the table predicts. For references a browser would upgrade, it also requests the https:// version, so an image that will break is labelled BROKEN before a reader finds it. It uses only the Python standard library.

#!/usr/bin/env python3
"""Find http:// subresources on an HTTPS blog and predict what browsers do with each.
Usage: python3 mixed_content_scan.py https://example.com/sitemap.xml  (or page URLs)"""
import re, sys, ipaddress, urllib.request
from html.parser import HTMLParser
from urllib.parse import urlsplit

UA = {"User-Agent": "mixed-content-scan/1.0"}

def get(url):
    with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=20) as r:
        return r.read().decode("utf-8", "replace")

def https_ok(url):
    u = "https://" + url[len("http://"):]
    try:
        with urllib.request.urlopen(urllib.request.Request(u, headers=UA), timeout=10) as r:
            return r.status < 400
    except Exception:
        return False

def is_ip(url):
    try:
        ipaddress.ip_address(urlsplit(url).hostname or "")
        return True
    except ValueError:
        return False

CSS_URL = re.compile(r"url\(\s*['\"]?(http://[^'\")\s]+)", re.I)

class Scan(HTMLParser):
    def __init__(self):
        super().__init__()
        self.found, self.in_picture, self.in_style = [], False, False
    def add(self, where, url, kind):
        self.found.append((where, url, kind))
    def handle_starttag(self, tag, attrs):
        a = dict(attrs)
        if tag == "picture": self.in_picture = True
        if tag == "style": self.in_style = True
        for part in (a.get("srcset") or "").split(","):
            u = part.strip().split(" ")[0]
            if u.startswith("http://"): self.add(f"<{tag} srcset>", u, "blocked")
        src = a.get("src") or ""
        if src.startswith("http://"):
            if tag == "source" and self.in_picture: self.add("<picture><source src>", src, "blocked")
            elif tag in ("img", "audio", "video", "source"): self.add(f"<{tag} src>", src, "upgraded")
            else: self.add(f"<{tag} src>", src, "blocked")
        if tag == "video" and (a.get("poster") or "").startswith("http://"):
            self.add("<video poster>", a["poster"], "upgraded")
        if tag == "link" and "stylesheet" in (a.get("rel") or "") and (a.get("href") or "").startswith("http://"):
            self.add("<link stylesheet>", a["href"], "blocked")
        for u in CSS_URL.findall(a.get("style") or ""):
            self.add("inline style url()", u, "upgraded")
    def handle_endtag(self, tag):
        if tag == "picture": self.in_picture = False
        if tag == "style": self.in_style = False
    def handle_data(self, data):
        if self.in_style:
            for u in CSS_URL.findall(data): self.add("<style> url()", u, "upgraded (blocked if a font)")

def pages(arg):
    if arg.endswith(".xml"):
        locs = re.findall(r"<loc>\s*([^<\s]+)\s*</loc>", get(arg))
        out = []
        for l in locs:
            out += pages(l) if l.endswith(".xml") else [l]
        return out
    return [arg]

def main():
    urls = [p for a in sys.argv[1:] for p in pages(a)]
    total, checked = 0, {}
    for page in urls:
        try:
            s = Scan(); s.feed(get(page))
        except Exception as e:
            print(f"ERROR  {page}  {e}"); continue
        for where, u, kind in s.found:
            total += 1
            if kind.startswith("upgraded"):
                if is_ip(u): kind = "blocked (IP address host)"
                else:
                    if u not in checked: checked[u] = https_ok(u)
                    if not checked[u]: kind = "upgraded, but https copy fails: BROKEN"
            print(f"{kind:40} {where:22} {u}  on {page}")
    print(f"\n{len(urls)} pages scanned, {total} http:// subresources found")

if __name__ == "__main__":
    main()

Run it with your sitemap or with individual post URLs:

python3 mixed_content_scan.py https://example.com/sitemap.xml

It prints one line per reference. With illustrative URLs, the output looks like this:

blocked                                  <img srcset>           http://old.example.com/wp-content/uploads/hero-1024x576.jpg  on https://example.com/hello-world
upgraded, but https copy fails: BROKEN   <img src>              http://photos.example.net/2014/cat.jpg  on https://example.com/my-cat

I checked the classifications against the Chromium test page above, then ran it over the 48 URLs in my own blog's sitemap, which returned no http:// subresources. It reads the HTML the server sends, so it won't see URLs inside external stylesheets or markup that JavaScript inserts after load, and it marks every url() inside a <style> block as an image even though a font loaded that way is blocked. Those gaps are small on most blogs, but if a theme uses custom fonts from an old host, open one page in DevTools as well.

Fix each reference at its source, by the kind of host

The script's output falls into four groups, and each has a different fix. References to your own old address are absolute http://yourdomain.com/... URLs written into posts before the site had HTTPS. Rewrite them to https:// in the content itself. On a WordPress install, WP-CLI's search-replace handles serialized data, and its documented examples skip the guid column. Run it with --dry-run first:

wp search-replace 'http://example.com' 'https://example.com' --skip-columns=guid --dry-run

If you're leaving WordPress, do the same replacement in the export file before importing it anywhere, so the new platform never stores the old scheme.

Third-party hosts that answer on HTTPS only need the scheme changed. The upgrade already loads them, but editing the URL removes the dependency on browser behavior, and it fixes the copy of your post that RSS readers and scrapers receive.

Hosts with no working HTTPS are where readers already see broken images. Download the files while the HTTP address still answers and serve them from your own blog, then point every reference at the new copy. If the HTTP address is already gone, the image has to come from a backup or the Wayback Machine.

Scripts and iframes are usually old embed codes and widgets. A blocked http:// embed is often a service that has since moved to HTTPS under a new snippet, so replace the snippet rather than editing the scheme.

Add upgrade-insecure-requests after the rewrite

The upgrade-insecure-requests directive tells the browser to rewrite every non-navigational http:// request to https:// before sending it. In the test, it rescued every blocked row: srcset images, the picture source, the stylesheet, the script and the iframe. It can be sent as a response header or placed in the page head:

<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests">

It works as a safety net for references the rewrite missed. It can't fix a host without HTTPS, which failed in both runs, and MDN notes there's no fallback to HTTP. It also only affects browsers loading your pages, so the http:// URLs remain in your stored content and in your feed. The upgraded script row shows the other reason to keep it as a backstop: a script you would have noticed as blocked now runs silently from whatever currently answers at that address.

On a host where you control headers, send it as a header instead so it covers every response.

I went down this path while moving old posts onto custom domains for JustBlogged. The srcset row is the one I didn't expect... a find-and-replace on src looks done, and the image still doesn't render

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.