How to Fix Mixed Content Errors on Every Page (2026 Guide)

Fix mixed content errors: what Chrome blocks vs auto-upgrades, how to find every http:// resource, and fixes for WordPress, CDNs and CSP. Data from 3,459 pages.

BugViso

14 min read

To fix mixed content errors, change every http:// subresource URL on your HTTPS pages (scripts, stylesheets, iframes, images, media and form actions) to https:// or a relative path, at the source: the template, the database content or the CDN setting that writes it. Content-Security-Policy: upgrade-insecure-requests is a useful safety net, but not a fix.

Mixed content has changed since most guides were written. Current Chrome blocks insecure scripts, stylesheets, iframes and fetches outright, and silently auto-upgrades insecure images, audio and video to https://. So the padlock rarely disappears any more. Instead, a widget doesn't load, or an image breaks because the https:// copy doesn't exist. Neither shows up as an obvious error.

On 9 October 2026 we checked 196 HTTPS homepages and 3,459 inner pages from a random sample. Homepages were almost clean, but 4.1% of sites still had insecure references on inner pages, and 2 of 5 auto-upgraded image URLs we tested returned 404 over HTTPS. This guide shows how to find every one and fix it per stack.


What Mixed Content Is (and Why Browsers Treat It in Two Ways)

A page is mixed content when its document loads over HTTPS but it pulls a subresource over plain HTTP. The W3C Mixed Content specification splits subresources into two classes:

  • Blockable (active) content can change the page: scripts, stylesheets, iframes, fetch()/XHR, web workers, <object> and <embed>. A network attacker who swaps an insecure script controls the whole page, so browsers block it.
  • Upgradeable (passive) content is display-only: <img>, <audio> and <video>. Chrome has rewritten these to https:// automatically since 2020, a change Google announced in No More Mixed Messages About HTTPS, and Firefox has since adopted the same behaviour.

MDN's mixed content reference tracks the current per-browser rules.

What Chromium actually does, resource by resource

We loaded a test page over HTTPS that referenced one insecure resource of each type, and recorded every network event and console message in Chromium 123 (Playwright build):

Insecure reference on an HTTPS pageWhat the browser didNetwork request madeConsole message level
<script src="http://…">BlockedNone (request failed before sending)Error
<link rel="stylesheet" href="http://…">BlockedNoneError
<iframe src="http://…">BlockedNoneError
fetch('http://…')Blocked, promise rejectsNoneError
<img src="http://…">Auto-upgradedYes, to https://…Warning
<video> / <audio src="http://…">Auto-upgradedYes, to https://…Warning
<form action="http://…">Loaded, warns on the formNot until submitWarning

Two consequences follow, and they shape how you have to test.

  1. There is never an http:// response to see. Blocked resources are stopped before any request goes out, and upgraded ones are fetched as https://. Any detector that looks for http:// URLs in the network log finds nothing. The console messages are the only place the original insecure URL appears.
  2. Auto-upgrade only works if the HTTPS copy exists. If https://old-cdn.example.com/photo.jpg returns 404, or the host has no certificate, the image just breaks. Nothing tells you the cause was mixed content.

How Common Is Mixed Content in 2026?

We used the random sample from our website accessibility statistics study: Tranco list 94GG2, ranks 1,001–50,000, fixed seed, 219 valid homepages, of which 196 were served over HTTPS and rendered fully in Chromium. For inner pages, we fetched up to 25 internal pages linked from each homepage (3,459 pages across 171 sites) and scanned the raw HTML, comments stripped, for insecure subresource references.

MeasurementResult
HTTPS homepages with a Mixed Content console message1 of 196 (0.5%)
Sites with at least one insecure reference on an inner page7 of 171 (4.1%)
Inner pages with an insecure reference27 of 3,459 (0.8%)
Insecure references by type (inner pages)26 <img>, 2 <script>, 2 stylesheets
Upgraded image URLs whose https:// copy returned 4042 of 5 unique URLs

The one homepage with live mixed content was loading decade-old social embed code over http://: a Facebook SDK, a Twitter widgets script and a retired Google +1 frame. All three were blocked, so the widgets never rendered.

The inner-page cases were the classic long tail: images uploaded years ago and hard-coded into posts with an absolute http:// URL, a stylesheet from a font service referenced over http://, and one leftover reference to a local development server (http://127.0.0.1:4000/…) that shipped to production.

💡 The pattern: templates get fixed during an HTTPS migration, but content stored in the database doesn't. Mixed content lives in old posts, product descriptions and CMS fields, which is exactly where nobody looks.


How to Find Mixed Content

1. Chrome DevTools (one page)

Open DevTools, go to Console, and filter by Mixed Content. Reload with the cache disabled (Network tab, Disable cache). Each message names the insecure URL and says whether it was blocked or upgraded:

text
Mixed Content: The page at 'https://example.com/' was loaded over HTTPS, but requested an
insecure script 'http://cdn.example.com/app.js'. This request has been blocked; the content
must be served over HTTPS.

The Issues tab groups the same messages under Mixed content.

2. Paste this into the console to list every insecure reference in the DOM

Console messages only cover what loaded. This snippet also catches srcset candidates the browser didn't pick, lazy-loaded images that haven't loaded yet, and form actions:

javascript
// Lists http:// subresource references in the current DOM (run on an https:// page)
const sel = 'img,source,script,link[rel~=stylesheet],link[rel~=icon],iframe,video,audio,embed,object,form';
console.table([...document.querySelectorAll(sel)].flatMap(el =>
  ['src', 'srcset', 'href', 'data', 'poster', 'action', 'data-src', 'data-srcset']
    .map(a => [a, el.getAttribute(a)])
    .filter(([, v]) => v && /(^|[\s,])http:\/\//i.test(v))
    .map(([a, v]) => ({ tag: el.tagName.toLowerCase(), attr: a, value: v.slice(0, 120) }))));

3. Scan a whole site from its sitemap

For more than a handful of pages, use a script. This one uses only the Python standard library. It reads URLs or a sitemap, strips HTML comments (old IE conditional comments with http:// scripts never load), reports what Chrome will do with each reference, and checks whether each auto-upgraded asset actually exists over HTTPS.

python
#!/usr/bin/env python3
"""find_mixed_content.py: find http:// subresources on HTTPS pages, and say what Chrome does with each.

Usage:
    python3 find_mixed_content.py https://example.com/page https://example.com/other
    python3 find_mixed_content.py --sitemap https://example.com/sitemap.xml --limit 200

Standard library only. Scans the raw HTML (comments stripped) for insecure script, stylesheet,
iframe, img/media, srcset, form action and inline CSS url() references, then checks whether
the same URL works over https:// (which is what Chrome silently requests for images and media).
"""
import argparse, re, ssl, sys, urllib.request
from urllib.parse import urljoin
from xml.etree import ElementTree

UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36"
COMMENT = re.compile(r"<!--.*?-->", re.S)
TAG = re.compile(r"<(script|img|iframe|source|video|audio|embed|object|link|form)\b([^>]*)>", re.I)
ATTR = re.compile(r"\b(src|srcset|data|poster|href|action)\s*=\s*([\"'])(.*?)\2", re.I | re.S)
CSSURL = re.compile(r"url\(\s*[\"']?(http://[^\"')\s]+)", re.I)
# What current Chrome does with each kind of insecure reference on an HTTPS page.
VERDICT = {"script": "BLOCKED", "stylesheet": "BLOCKED", "iframe": "BLOCKED", "object": "BLOCKED",
           "embed": "BLOCKED", "form": "WARNING on submit", "css-url": "upgraded (font/image)",
           "img": "auto-upgraded", "source": "auto-upgraded", "video": "auto-upgraded", "audio": "auto-upgraded"}


def get(url, method="GET"):
    req = urllib.request.Request(url, headers={"User-Agent": UA}, method=method)
    return urllib.request.urlopen(req, timeout=20, context=CTX)


def scan(html):
    html = COMMENT.sub("", html)
    for tag, attrs in TAG.findall(html):
        tag = tag.lower()
        for name, _, val in ATTR.findall(attrs):
            name = name.lower()
            if tag == "link" and (name != "href" or not re.search(r"rel\s*=\s*[\"'][^\"']*(stylesheet|icon|preload)", attrs, re.I)):
                continue
            if tag not in ("link", "form") and name == "href":
                continue
            for part in re.split(r",\s*", val) if name == "srcset" else [val]:
                u = part.strip().split()[0] if part.strip() else ""
                if u.lower().startswith("http://"):
                    kind = "stylesheet" if tag == "link" else tag
                    yield kind, u
    for u in CSSURL.findall(html):
        yield "css-url", u


def https_ok(url):
    try:
        with get("https://" + url[7:], "HEAD") as r:
            return r.status < 400
    except Exception:
        return False


def sitemap_urls(url, limit):
    with get(url) as r:
        root = ElementTree.fromstring(r.read())
    ns = {"s": "http://www.sitemaps.org/schemas/sitemap/0.9"}
    locs = [e.text.strip() for e in root.findall(".//s:loc", ns)]
    if root.tag.endswith("sitemapindex"):
        out = []
        for child in locs:
            out += sitemap_urls(child, limit - len(out))
            if len(out) >= limit:
                break
        return out[:limit]
    return locs[:limit]


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("urls", nargs="*")
    ap.add_argument("--sitemap")
    ap.add_argument("--limit", type=int, default=100)
    ap.add_argument("--insecure", action="store_true", help="skip TLS verification (local testing only)")
    a = ap.parse_args()
    CTX = ssl.create_default_context()
    if a.insecure:
        CTX.check_hostname, CTX.verify_mode = False, ssl.CERT_NONE
    pages = a.urls + (sitemap_urls(a.sitemap, a.limit) if a.sitemap else [])
    found = 0
    for page in pages:
        try:
            with get(page) as r:
                html = r.read(3_000_000).decode(r.headers.get_content_charset() or "utf-8", "replace")
        except Exception as e:
            print(f"? {page}: {e}")
            continue
        hits = sorted(set(scan(html)))
        if not hits:
            continue
        print(f"\n{page}")
        for kind, u in hits:
            found += 1
            u = urljoin(page, u)
            note = "" if VERDICT.get(kind, "").startswith(("BLOCKED", "WARNING")) else (
                "  https copy OK" if https_ok(u) else "  ✗ https copy FAILS, so it breaks after upgrade")
            print(f"  {VERDICT.get(kind, 'check'):22s} {kind:10s} {u}{note}")
    print(f"\n{found} insecure reference(s) on {len(pages)} page(s)")
    sys.exit(1 if found else 0)

Output against our test page:

text
https://test.example/index.html
  auto-upgraded          audio      http://example.com/sound.mp3  ✗ https copy FAILS, so it breaks after upgrade
  WARNING on submit      form       http://example.com/submit
  BLOCKED                iframe     http://example.com/
  auto-upgraded          img        http://example.com/photo.png  ✗ https copy FAILS, so it breaks after upgrade
  BLOCKED                script     http://example.com/app.js
  BLOCKED                stylesheet http://example.com/style.css
  auto-upgraded          video      http://example.com/clip.mp4  ✗ https copy FAILS, so it breaks after upgrade

7 insecure reference(s) on 1 page(s)

A static scan can't see URLs built at runtime, such as fetch('http://' + host). Use the DevTools console for JavaScript-heavy pages.


Fixes by Root Cause

1. Hard-coded URLs in templates

html
<!-- ❌ Blocked: insecure stylesheet and script -->
<link rel="stylesheet" href="http://fonts.googleapis.com/css?family=Open+Sans">
<script src="http://cdn.example.com/widget.js"></script>

<!-- ✅ Fixed: explicit https (avoid protocol-relative //, which is an outdated pattern) -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Open+Sans">
<script src="https://cdn.example.com/widget.js"></script>

If a third-party host has no HTTPS at all, that dependency has to go: self-host the file or replace the vendor. Upgrading the URL won't help.

2. Absolute URLs stored in WordPress content

WordPress stores full URLs in post content, metadata and options. After an HTTPS move, old posts keep http://example.com/wp-content/uploads/... until you rewrite them. Use WP-CLI, which handles serialized data safely, and always do a dry run first:

bash
# Dry run: how many rows would change
wp search-replace 'http://example.com' 'https://example.com' --all-tables --precise --dry-run

# Back up, then run it for real
wp db export before-https-fix.sql
wp search-replace 'http://example.com' 'https://example.com' --all-tables --precise --skip-columns=guid

Skip the guid column: WordPress uses it as a permanent post identifier in feeds, so it shouldn't be rewritten. Then set Settings → General (siteurl and home) to https:// and clear every cache layer: page cache, object cache and CDN.

3. Other CMSs and databases

Find the scale of the problem first, then fix it with your CMS's own tools rather than raw SQL where you can:

sql
-- MySQL/MariaDB: count rows still referencing http:// assets on your own domain
SELECT COUNT(*) FROM posts WHERE body LIKE '%src="http://example.com/%';

4. CDN and image-service URLs

Image CDNs and asset hosts are often configured with an http:// base URL in an environment variable or plugin setting, so every generated URL inherits it. Fix it at the base URL (ASSET_PREFIX, CDN_URL, the plugin's "CDN hostname"), then purge the CDN cache so stored HTML with the old URLs is regenerated.

5. Third-party embed code

Old copy-paste snippets for social widgets, chat tools and analytics often use http://. Replace them with the vendor's current snippet, not just a scheme swap: many old endpoints (like the Google +1 frame we found) no longer exist.

6. The safety net: upgrade-insecure-requests

http
Content-Security-Policy: upgrade-insecure-requests
html
<!-- or, if you can't set headers -->
<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests">

This CSP directive tells the browser to rewrite every http:// subresource, including scripts, to https:// before requesting it. That turns blocked resources into working ones if the HTTPS copy exists. It doesn't fix links you navigate to, and it fails the same way as auto-upgrade when the HTTPS copy is missing. In our security headers statistics study, 15 of the 65 sites with a CSP used this directive. Treat it as protection for content you haven't cleaned yet, not as the clean-up.


How BugViso Detects Mixed Content

BugViso loads the scanned page in a real Chromium browser and records every Mixed Content message the browser raises. That covers blocked scripts, stylesheets, frames and fetches, auto-upgraded images and media, and forms that post to http://. Each one is logged with the insecure URL, its resource type and whether it was blocked or upgraded. Because detection reads the browser's own mixed-content decisions rather than the network log, it catches the resources that never produce an http:// response.

The Security section of the results and the PDF security checklist show the mixed-content count next to the HTTPS-redirect check, the TLS certificate and the security header grade. The full URL list, with types, is in the audit JSON export. Each insecure resource costs 2 points of the health score, up to 8. The PDF report's remediation playbook includes the upgrade-insecure-requests safety net and the instruction to replace hard-coded http:// asset URLs. The same blocked requests also show up in the console-error log for the page.

You can check any page for mixed content with a free BugViso scan. Pair it with the sitemap script above to cover old content in bulk. Everything that runs in the security pass is listed on the security and privacy audit page.


Traps and Edge Cases

  • srcset hides insecure candidates. The browser picks one candidate per viewport, so an http:// URL in the 2x candidate only appears on high-density screens. Scan the attribute, not just the loaded image.
  • Lazy-loaded images don't load until you scroll. Their data-src values often hold old http:// URLs that a load-time check never sees. The DevTools snippet above checks data-src and data-srcset too.
  • Redirects can create mixed content. An https:// asset that 301s to http:// is blocked like any other insecure script. curl -sIL https://cdn.example.com/app.js | grep -i location shows the chain.
  • Insecure form actions leak data even with a padlock. A login or newsletter form posting to http:// sends the data in plain text. Chrome warns on submit, but the page itself looks secure.
  • Links aren't mixed content. <a href="http://..."> isn't a subresource. It's a navigation. Still update your own internal links, because each one costs a redirect hop. See redirect chains and loops for why that adds up.

FAQ

What causes mixed content errors?

An HTTPS page referencing a subresource (script, stylesheet, iframe, image, media or form target) by an http:// URL. Usually the URL is hard-coded in a template, stored in old CMS content, or generated from an http:// CDN base URL.

Does mixed content affect SEO?

Not directly as a ranking signal. The indirect effects are real, though. Blocked scripts and stylesheets can break layout and functionality, and broken upgraded images lose image search visibility. Insecure forms also erode user trust.

Why don't I see the "not secure" warning any more?

Because browsers now block active mixed content and auto-upgrade passive content, the page stays technically secure. The cost moves to broken resources instead of a warning, which is why you have to look for it deliberately.

Is upgrade-insecure-requests enough to fix mixed content?

It fixes it for visitors only when every referenced resource exists over HTTPS. Use it as a safety net while you fix URLs at the source, because it can't repair a missing HTTPS copy.

How do I fix mixed content in WordPress?

Run wp search-replace 'http://yourdomain' 'https://yourdomain' --all-tables --precise --skip-columns=guid after a backup and a --dry-run. Then update the site URLs in Settings → General and purge all caches.


Conclusion

Mixed content in 2026 doesn't remove the padlock. It silently blocks widgets and breaks images in old content, so find it in the console and the database rather than waiting for a warning, and let a BugViso scan list every insecure URL the browser rejected.

Found this useful? Share it.

See where your site stands

Run a free BugViso audit for SEO, speed, accessibility and AI search readiness — with fixes you can ship today.