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
Top comments (0)