What the browser is complaining about

Your page arrived over HTTPS, but something on it asked for a resource over plain http://. That resource could be read or altered by anyone on the network path, which would undo the point of the HTTPS page. So the browser either blocks the request or retries it over HTTPS.

In Chrome’s console a blocked request looks like this:

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

and an upgraded one says This request was automatically upgraded to HTTPS instead.

Which of the two happens depends on what kind of resource it is.

Blocked vs upgraded

The W3C Mixed Content spec splits resources into two groups.

Blockable (sometimes called active mixed content): scripts, stylesheets, iframes, web fonts, fetch() and XHR, WebSockets, workers, and in practice anything that isn’t in the second group. These can read or rewrite the page, so a tampered copy is as bad as an attacker running code on your site. Browsers block them. Nothing loads, and whatever depended on them breaks: layout without the stylesheet, a dead button without its script, an empty box where the iframe was.

Upgradeable (formerly passive or display content): images, audio and video loaded by <img>, <audio> and <video>. A tampered image is bad but contained. Current Chrome and Firefox no longer load these over HTTP; they quietly rewrite the URL to https:// and try that. If the host serves the same file over HTTPS, you never notice. If it doesn’t, the image is simply missing, with a console message but no other sign. Older browsers loaded them over HTTP and downgraded the address-bar indicator instead.

That split explains the usual confusing symptom: “the images work but the slider doesn’t”. The images were upgraded and the host happened to support HTTPS; the slider’s script was blocked.

You’re a visitor: what you can do

If you’re just trying to use a site that’s broken by mixed content, the fix belongs to the site owner. You can tell them which resource is failing (the console message names it).

Chrome does let you switch the protection off for a single site: click the site-information icon to the left of the address, open Site settings, and set Insecure content to Allow. Reload. Other browsers have similar per-site options.

Only do this for a site you trust, on a network you trust, and treat it as temporary. It turns an HTTPS page back into one where a script fetched over HTTP can be swapped by anyone in between, which is exactly the attack the block exists for. There is no setting that fixes the page for other visitors.

You run the site: find every http:// reference

Fix the source. Don’t rely on the browser’s upgrade to hide it: blockable content is never upgraded automatically, and older browsers don’t upgrade anything.

In DevTools

Open the failing page with DevTools already open (F12), then reload:

  • Console: every mixed request gets its own message, with the page URL and the resource URL. Filter the console by typing Mixed Content.
  • Network: type mixed-content:all in the filter box to list every mixed request on the page, or mixed-content:displayed for the ones that were actually loaded.

Click through the site’s important templates: home, an article, a product page, checkout, the logged-in area. Mixed content is usually per template, not per site.

From the command line

For server-rendered HTML, a rough grep finds most references:

curl -s https://example.com/some-page | grep -oE '(src|href|action)="http://[^"]+"' | sort -u

It misses URLs that JavaScript builds at runtime and url(http://...) inside CSS files. For stylesheets, fetch each one and grep it the same way.

Across the whole site, from real browsers

A report-only Content Security Policy makes visitors’ browsers report every non-HTTPS load without blocking anything:

Content-Security-Policy-Report-Only: default-src https: data: blob: 'unsafe-inline' 'unsafe-eval'; report-uri /csp-report

Every request that isn’t https: (or data:/blob:) produces a JSON report to /csp-report, naming the page and the blocked URL. Leave it on for a few days and you’ll find the pages nobody thought to click. report-uri is officially superseded by report-to, but browsers still honour it and it’s simpler to set up.

Fixing it, cause by cause

Hardcoded http:// in templates and code

The simplest case: a theme, template or script has http:// written into it. Change it to https://. Before you do, open the https:// version of each URL in a browser to make sure that host actually serves the file over HTTPS. Protocol-relative URLs (//cdn.example.net/x.js) also work, but there’s no longer any reason to serve anything over HTTP, so just write https://.

http:// URLs stored in the database

On a CMS, most mixed content lives in content, not code: image URLs inside old posts, widget settings, menu links, all stored as http://example.com/... from before the site moved to HTTPS.

For WordPress, first set Settings → General → WordPress Address and Site Address to https://. Then rewrite the stored URLs with WP-CLI:

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

The dry run prints how many replacements it would make in each table. Use WP-CLI rather than a raw SQL REPLACE(). WordPress stores many settings as serialized PHP, where each string carries its length (s:18:"http://example.com"). Changing http to https adds one character, the stored length no longer matches, and PHP refuses to unserialize the value. Widgets and theme options silently reset. wp search-replace rewrites the lengths for you. --skip-columns=guid leaves post GUIDs alone, which WordPress recommends never changing.

Other CMSes have the same problem with different tools. The principle is the same: back up, replace with something that understands the storage format, check the result.

The app thinks it’s running on HTTP

This one creates mixed content you can’t find in the code. HTTPS ends at a load balancer, CDN or reverse proxy, and the request reaches your application over plain HTTP. The application looks at its own connection, concludes the scheme is http, and builds every absolute URL (assets, canonical tags, redirects) with http://.

The fix is to pass the original scheme through and tell the application to trust it. In nginx in front of an app:

proxy_set_header X-Forwarded-Proto $scheme;

Then on the application side:

  • Django: SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
  • Express: app.set("trust proxy", 1) (the number of proxies in front of the app)
  • WordPress behind a proxy, in wp-config.php above the “stop editing” line:
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
    $_SERVER['HTTPS'] = 'on';
}

Only trust the header from your own proxy. If the application is reachable directly, anyone can send X-Forwarded-Proto: https.

If the proxy is Cloudflare in Flexible mode, the edge talks to your origin over HTTP by design. Moving to Full (strict) fixes the cause instead of the symptom; the Cloudflare SSL modes guide explains the difference.

A third-party host with no HTTPS

An old widget, ad server, analytics script or image host that only answers on port 80. No configuration on your side can make that work: an upgraded request has nowhere to land, and a blockable one is blocked either way.

Your options, in order: find the HTTPS URL the vendor now uses (most moved years ago), download the file and serve it yourself over HTTPS if the licence allows, or drop it. You can check whether a host answers on HTTPS at all, and whether its certificate is valid, with the SSL check.

Forms posting to http://

A form on an HTTPS page whose action is http://... is mixed content too. Chrome warns the user before submitting it, and the data travels in the clear if they continue. Point the action at https://, or at a relative path.

upgrade-insecure-requests as a safety net

Once the known references are fixed, you can tell browsers to upgrade any you missed:

Content-Security-Policy: upgrade-insecure-requests

or, if you can’t set headers, in the page:

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

With this directive the browser rewrites every http:// subresource URL on the page to https:// before requesting it, blockable ones included, third-party hosts included. It also upgrades links that navigate to your own site. It doesn’t upgrade links to other sites.

Two limits. It assumes every host serves the same content over HTTPS; where one doesn’t, the request fails instead of being blocked, so the page is still broken. And it only applies to pages that send it, so it’s not a substitute for HSTS, which makes the browser use HTTPS for your whole site, including the first request a visitor types. See the HTTP security headers guide for how the two fit together.

You may also come across block-all-mixed-content. It’s deprecated and does nothing useful in current browsers, since they already block what it would block.

Checking that it’s fixed

  • Reload each important template with DevTools open and confirm the console has no Mixed Content messages and mixed-content:all shows nothing in Network.
  • Confirm the page itself is served over HTTPS, with HTTP redirecting to it, and see which Content-Security-Policy it sends, using the HTTP check.
  • Leave the report-only CSP running for a while longer; it will catch the pages you didn’t click.

Check your own domain now

Free, no sign-up. Runs the exact check this guide describes and shows what to fix.