Skip to content
RU
← All articles

Mixed Content: How to Find and Fix HTTP on HTTPS Sites

In short. Mixed content is a subresource loaded over http:// on a page served over https://. Active content (scripts, stylesheets, iframes, fetch, WebSocket) is blocked outright: a tampered script would own the whole page. Passive content (images, video, audio) is silently upgraded to HTTPS and dropped if the upgrade fails. It is not fixed by one header — it is fixed layer by layer: templates, database content, serialized settings, third-party widgets.

What mixed content is and why browsers cut it

HTTPS gives a page three guarantees: confidentiality (the traffic cannot be read), integrity (the response cannot be altered) and authentication (you are talking to the server named in the certificate). How that works in detail is covered in the SSL/TLS guide.

A single subresource loaded over http:// destroys the second and third guarantees for the entire page. An attacker on the network path (public Wi-Fi, a compromised home router, a transparent ISP proxy, a transit network) does not need to break TLS. They only need one unencrypted request to answer with their own payload.

What happens next depends on what exactly you loaded over HTTP:

  • An image gets swapped. A bank logo, a "Pay now" button, a screenshot in your instructions. On top of that the request itself leaks to a third party: the page URL in Referer, the IP, the User-Agent.
  • A script lets the attacker execute code inside your origin: read and write the DOM, read document.cookie (everything not marked HttpOnly), intercept form submissions, replace payment details, steal tokens from localStorage. There is effectively no difference from a full XSS.
  • A stylesheet repaints the page, hides the real form and shows a fake one, pulls in arbitrary fonts and background images, and via @import can drag in even more code.

That is why browsers do not settle for a warning. For active content, blocking is the only safe default, and a site cannot opt out of it.

Mixed content is not cosmetics and not "the padlock turned grey". It is a hole in exactly the same class as XSS — only exploited from a network position instead of through user input.

What is NOT mixed content

  • A plain link <a href='http://example.com'>. That is a top-level navigation, not a subresource of the current page. The browser will follow it and show "Not secure" on the new page. Those are fixed by redirects and HSTS, not by CSP.
  • Requests made by your server. If your backend calls an external API over HTTP, the browser never sees it. The problem is real, but you look for it in application logs, not in the console.
  • The data: and blob: schemes — considered potentially trustworthy and never blocked.
  • http://localhost, http://127.0.0.1, *.localhost — the spec treats these as potentially trustworthy origins. Mixed content does not reproduce on a local dev box, which is the classic reason for "works on my machine".

Passive vs active mixed content: the distinction that decides everything

The W3C Mixed Content specification splits insecure subresources into two classes, and the class determines both browser behaviour and your fix strategy.

Passive (display) mixed content

Images, video and audio — content that is placed into the page but cannot execute code and has no DOM access. The spec calls it optionally-blockable.

Browsers used to simply load such a resource and downgrade the padlock. That is no longer the behaviour. Today the browser first retries the same URL over HTTPS (an automatic upgrade), and only if the HTTPS request fails is the resource not rendered at all. Chrome, Firefox and Safari adopted this model in different releases, but the direction is the same everywhere.

The practical consequence changes how you debug: "passive" no longer means "loads with a warning". If the third-party host has no HTTPS, or the certificate expired, or the certificate chain is incomplete, the image simply disappears. And the console will show a network error such as net::ERR_CERT_AUTHORITY_INVALID rather than "Mixed Content" — the link back to the original http URL in the markup is far from obvious.

Active (blockable) mixed content

Everything that can execute or influence execution: <script>, <link rel='stylesheet'>, <iframe>, <object>, <embed>, web fonts, XHR and fetch(), EventSource, Web Workers, WebSocket over ws://, navigator.sendBeacon().

Such a request is blocked with no upgrade attempt and no way for the site to allow it. The console shows:

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.

The word right after "insecure" is the most useful part of the message: script, stylesheet, frame, font, XMLHttpRequest endpoint. It tells you immediately which layer to fix.

ResourceClassWhat the browser doesHow to fix
<img>, <picture>, srcsetPassiveUpgraded to HTTPS; not rendered if the upgrade failsReplace URLs in content and media library
CSS background-image: url(http://…)PassiveUpgraded; no background if it failsFix the source SCSS/CSS and rebuild
<video>, <audio>, posterPassiveUpgraded; empty player if it failsMove media to an HTTPS host
<link rel='icon'> (favicon)PassiveUpgraded; default icon if it failsUse a relative path instead of absolute
<script src>ActiveBlocked completelyHTTPS URL, self-host, or replace
<link rel='stylesheet'>, @importActiveBlocked completelyHTTPS URL or a local copy
Web fonts in @font-faceActiveBlocked; text falls back to a system fontSelf-host the fonts
<iframe src>ActiveBlocked; empty frameHTTPS embed, proxy, or replace the service
<object>, <embed>ActiveBlockedDrop the plugin, use an HTML5 equivalent
XHR, fetch(), EventSourceActiveBlocked, the promise rejectsEnable HTTPS on the API endpoint
WebSocket ws://ActiveBlocked on openMove to wss://
navigator.sendBeacon(), WorkerActiveBlocked silentlyHTTPS collection endpoint
<form action='http://…'>Special caseNot mixed content, but the browser warns about submitting in the clearHTTPS in action
<a href='http://…'>Not countedNavigation is allowedRedirect plus HSTS on the target domain
Diagram: an HTTPS page loading some subresources over HTTP, active ones blocked, passive ones upgraded
Active content is blocked with no alternatives; passive content is first retried over HTTPS.

What actually breaks: symptom, cause, check

Site owners rarely arrive saying "I have mixed content". They arrive with a symptom. The table below maps one to the other.

SymptomWhat happenedHow to checkFix
Page falls apart, bare HTML with no stylingThe main CSS bundle was blockedConsole: insecure stylesheetHTTPS URL for the stylesheet in the template
Menus, sliders and modals stop workingjQuery or the script bundle was blockedConsole: insecure script plus $ is not definedHTTPS CDN or a local copy
Form submits into the void, no responseA fetch to an http API was blockedNetwork: request shows (blocked:mixed-content)HTTPS on the endpoint
Analytics stopped recording visitsThe analytics tag was blockedThe request is missing from NetworkUpdate the tracker snippet
Empty rectangle instead of a map or videoAn iframe was blockedConsole: insecure frameHTTPS embed from the provider
Text shifts and renders in a system fontA web font was blockedConsole: insecure fontSelf-host the fonts
Images vanished from older articlesPassive upgrade failed: the host has no HTTPSNetwork: certificate error on the source domainRe-upload the images to your own storage
Padlock is grey but the page looks fineA single http image or faviconMixed content checkReplace that one URL
Breaks only for some usersThe resource loads in a rare flow — cart, account, searchCSP Report-Only on live trafficFix the URLs reported

One trap deserves its own line: the set of scripts loaded for a signed-in user is usually different from the anonymous one. Testing only the homepage in a private window is a reliable way to miss half the problems.

How to find every occurrence of mixed content

No single method finds everything. The workable approach is four independent passes: the browser, CSP reports from live traffic, a crawler, and grep across code and the database dump.

DevTools: Console, Issues and Network

Quick check of a single page:

  1. Console — filter by Mixed Content. Shows both blocked and upgraded resources.
  2. Issues (a separate tab) — groups violations by type and links to the source line.
  3. Network — disable cache, hard-reload with Ctrl+Shift+R. Blocked requests appear with status (blocked:mixed-content). The Initiator column tells you who triggered the request — the only reliable way to catch a resource injected by a third-party script at runtime.
  4. Security — a summary of the page origin and the list of insecure sources.

The limitation is obvious: you only see the pages you open and the flows you walk.

CSP Report-Only: collecting violations from live traffic

This is the strongest method. You ship a report-only policy that blocks nothing but sends a JSON report for every violation — from real pages opened by real users. The trick is to allow any source over the https: scheme and nothing else: every http resource then becomes a violation.

# /etc/nginx/conf.d/csp-report-only.conf
# Blocks nothing. One job: collect the list of http subresources
# across all pages and all flows, including account pages and checkout.

add_header Reporting-Endpoints 'csp-endpoint="https://example.com/_csp"' always;
add_header Content-Security-Policy-Report-Only "default-src https: data: blob: 'unsafe-inline' 'unsafe-eval'; report-uri /_csp; report-to csp-endpoint" always;

# 'unsafe-inline' and 'unsafe-eval' are deliberate here: they remove the noise
# from inline scripts so that only scheme violations remain in the reports.
# report-uri is kept for older browsers, report-to is the current mechanism.

Three traps that eat time:

  • nginx add_header inheritance. If a nested location defines even one add_header of its own, all parent headers disappear inside that block. Verify the actual response, not the config file.
  • upgrade-insecure-requests is ignored in Report-Only. The spec states this explicitly. If you put the upgrade and the reporting in the same report-only policy, no upgrade happens.
  • Reporting does not work via <meta>. The report-uri, report-to, frame-ancestors and sandbox directives are ignored in a meta tag. HTTP header only.

The report format and the usual policy mistakes are covered in the guides on setting up Content-Security-Policy and fixing CSP errors.

Crawling the site

CSP reports only cover pages people actually visit. Old articles nobody has opened in years are found only by a crawl. The minimal version walks the sitemap and counts http subresources per page:

# How many http subresource URLs appear in each page of the sitemap
curl -sL https://example.com/sitemap.xml \
  | grep -oE '<loc>[^<]+' | cut -c6- \
  | while read -r u; do
      n=$(curl -sL --max-time 20 "$u" | grep -coiE '="http://[^"]+"')
      [ "$n" -gt 0 ] && printf '%5s  %s\n' "$n" "$u"
    done | sort -rn | head -50

# All http subresources on one page, with the attribute that carries them
curl -sL https://example.com/blog/old-post/ \
  | grep -oiE '(src|href|action|poster|data-src|data-lazy-src)="http://[^"]+"' \
  | sort -u

# http inside a compiled stylesheet: url() and @import
curl -sL https://example.com/assets/app.css \
  | grep -oE '(url\(|@import[[:space:]]+)["'"'"']?http://[^)"'"'"']+' | sort -u

This will not see resources injected by JavaScript after load — for those you need a headless browser or CSP reports. The same crawl is useful for finding dead URLs: see the broken link checker.

grep across sources and the database dump

# 1. Sources: templates, markup, compiled bundles
grep -rnE 'http://[a-z0-9.-]+' \
  --include='*.html' --include='*.php' --include='*.twig' --include='*.tpl' \
  --include='*.js' --include='*.jsx' --include='*.ts' --include='*.tsx' \
  --include='*.css' --include='*.scss' --include='*.vue' --include='*.xml' \
  . | grep -vE '(xmlns|schema\.org|w3\.org|purl\.org|DOCTYPE|localhost|127\.0\.0\.1)'

# xmlns and schema.org are filtered out on purpose: they are namespace
# identifiers, not fetch targets. The browser never requests them.

# 2. Protocol-relative URLs deserve their own list
grep -rnE '(src|href)="//[a-z0-9.-]+' --include='*.php' --include='*.html' .

# 3. Database dump: which hosts appear at all, and how often
mysqldump --no-tablespaces --single-transaction -u user -p sitedb \
  | grep -oE 'http://[a-zA-Z0-9._-]+' | sort | uniq -c | sort -rn | head -30

# 4. Same for JSON fields where slashes are escaped (page builders)
mysqldump --no-tablespaces --single-transaction -u user -p sitedb \
  | grep -oE 'http:\\/\\/[a-zA-Z0-9._-]+' | sort | uniq -c | sort -rn | head -30

The fourth command matters more than it looks. Page builders store block settings as JSON where / is escaped as \/. A plain search for http:// never finds those URLs, and after a "complete" replacement the site keeps pulling http images.

Four discovery channels for mixed content: DevTools, CSP reports, a crawler, and grep across code and database
No single channel covers everything: the browser sees opened pages, CSP sees live traffic, the crawler sees forgotten sections, grep sees the sources.

Why grepping the theme folder never finds everything

The most common mistake is to run grep over the theme directory, get zero matches and declare victory. URLs live far outside the repository:

  • Content in the database. Posts and pages written before the HTTPS switch contain absolute http:// URLs right inside the stored HTML.
  • Serialized settings. Theme options, widget configuration and plugin settings are stored as PHP-serialized strings. The same absolute URLs are inside them.
  • Page builders. Block data is kept as JSON with escaped slashes, sometimes base64 on top of that.
  • Ad slots and banners. The code is pasted into a "custom HTML" field in the admin panel and does not exist in your project files at all.
  • Tags in a tag manager. A script added to a GTM-style container is not stored on your side at all. It arrives at runtime and is not in git.
  • Second-level third-party widgets. You embed an HTTPS chat script and it pulls an http resource from inside. Your code is clean; the console is not.
  • User-uploaded material. Avatars, reviews with pictures, attachments imported long ago from an external host.
Where URLs liveHow to find themHow to fix them
Templates, markup, componentsgrep -rnE over sourcesCode change and release
Compiled CSS/JS bundlesgrep in dist/ plus curl on the bundleFix the sources and rebuild
Post and page bodies in the DBgrep over the dumpSerialization-aware replace tool
Serialized options and meta fieldsgrep the dump for s:NN:"http://Only via unserialize/serialize
Page builder JSONSearch for http:\\/\\/Replace with escaping in mind
Banners and custom HTML in the adminManual walk through admin sectionsManual edit
Tags in a tag managerNetwork → Initiator in the browserEdit the tag in the vendor UI
Third-party widgets and their nested callsCSP reports, NetworkVersion update, vendor request
Email and newsletter templatesgrep over templates plus a test sendTemplate edit
RSS/XML feeds, sitemapcurl plus grep on <loc> and <url>Regenerate after the DB fix
JSON-LD, canonical, og:image, hreflangHeader and HTML inspectionFix the meta template
CMS settings: site URL, CDN domainAdmin panel, config files, .envChange the value and purge caches

Fixing it layer by layer

Order matters: code first, then the database, then settings. Start with the database and the next deploy will bring the http URLs back from the templates.

Layer 1. Templates and compiled assets

The rule is simple: root-relative paths for your own resources, explicit https:// for external ones.

<!-- Bad: hardcoded http -->
<script src='http://cdn.example.net/lib.js'></script>
<link rel='stylesheet' href='http://example.com/assets/app.css'>

<!-- Bad: protocol-relative URL, an anti-pattern in 2026 -->
<script src='//cdn.example.net/lib.js'></script>

<!-- Good: your own resource, root-relative path -->
<link rel='stylesheet' href='/assets/app.css'>

<!-- Good: external resource, explicit https -->
<script src='https://cdn.example.net/lib.js'
        integrity='sha384-...' crossorigin='anonymous'></script>

A root-relative path beats an absolute one for your own files: it survives a domain change, works on a staging copy, and physically cannot become mixed content.

Protocol-relative // is an anti-pattern today

The //cdn.example.net/lib.js syntax was invented when a site lived on HTTP and HTTPS at the same time and it mattered not to mix protocols. That era is over: HTTPS is the only working option. What remains is only downsides:

  • A file opened locally over file:// turns such a URL into file://cdn.example.net/… and the resource never loads. Offline markup review and email previews break.
  • You cannot copy the URL out of the code and open it, or check it with curl, without editing it by hand.
  • It hides intent: the code does not tell you whether the source supports HTTPS at all.
  • If the page is ever served over HTTP (staging, a local proxy, a saved copy), the resource quietly drops to HTTP and becomes mixed content somewhere else.

Write https:// explicitly. The only place // still shows up with any justification is vendor snippets you do not edit — and those are worth replacing with a current version anyway.

Layer 2. Bulk replacement in the database and the serialization trap

This is where sites get broken most often. The naive command looks harmless:

-- DO NOT DO THIS on tables that hold serialized data
UPDATE wp_posts
   SET post_content = REPLACE(post_content, 'http://example.com', 'https://example.com');

-- Why: PHP serialization stores the string length in bytes.
--   before: s:27:"http://example.com/logo.png"
--   after:  s:27:"https://example.com/logo.png"   ← the length is now 28
-- unserialize() returns false and widget/theme settings are silently lost.
-- JSON columns fail the other way: the URL is stored as
-- http:\/\/example.com and never matches the REPLACE condition at all.

The correct path is a tool that understands the storage format: it parses the value, replaces the string, recalculates the length and packs it back.

# WordPress: WP-CLI. ALWAYS dry-run first.
wp search-replace 'http://example.com' 'https://example.com' \
  --all-tables-with-prefix --precise --recount --skip-columns=guid \
  --report-changed-only --dry-run

# Happy with the table list and the number of replacements? Run it for real.
wp search-replace 'http://example.com' 'https://example.com' \
  --all-tables-with-prefix --precise --recount --skip-columns=guid

# Separate pass for the escaped form used by page builders.
wp search-replace 'http:\/\/example.com' 'https:\/\/example.com' \
  --all-tables-with-prefix --precise --dry-run

# Take a dump before any pass. Rollback must take minutes, not hours.
wp db export backup-$(date +%F-%H%M).sql

Why those flags: --precise forces the replacement to run in PHP rather than SQL, which is the only way serialization is handled correctly. --skip-columns=guid is mandatory — guid is a permanent post identifier used by feed readers, and changing it re-delivers every old post to subscribers. --all-tables-with-prefix picks up plugin tables that are not part of the standard set.

Other platforms follow the same principle in different places:

  • Drupal: node bodies in node__body, configuration in the config table — serialized.
  • Magento: CMS blocks and pages, plus core_config_data where base URLs and media URLs live.
  • Custom applications: look for TEXT/LONGTEXT columns that store editor HTML, and any column holding JSON settings.

The rule before any bulk replace: take a dump, run a dry pass, note the expected number of replacements, apply, then check five random pages across different content types. Skipping any step eventually costs an evening of restore work.

Layer 3. Settings, caches and CDN

  • The site URL in CMS settings and in application configuration (.env, config/*.php) — switch it to https://.
  • The media base URL and CDN domain deserve a separate check: they are often stored in their own setting and are missed by a blanket replace.
  • Full purge of page cache, object cache and CDN cache. Otherwise neither users nor your own crawler will see the corrected HTML.
  • Regenerate the sitemap and RSS feeds after the database fix.
Layers of a mixed content fix: templates, database with serialized fields, settings and caches
The order is not optional: start with the database and the next deploy restores http URLs from the templates.

CSP directives: upgrade-insecure-requests and the obsolete block-all-mixed-content

upgrade-insecure-requests is a stopgap

The directive makes the browser rewrite every http:// request of the page to https:// before it is sent. Ship it as a header:

# nginx: a stopgap for the duration of the cleanup.
# Do not keep it longer than one release cycle.
add_header Content-Security-Policy "upgrade-insecure-requests" always;

# Verify the header is actually served:
curl -sI https://example.com/ | grep -i 'content-security-policy'

# The meta alternative works but is weaker: the header applies to every
# response, while meta applies only to this document and only to requests
# started AFTER the tag is parsed. Put it first in head.
# <meta http-equiv='Content-Security-Policy' content='upgrade-insecure-requests'>

What it does: upgrades subresources, nested navigations (iframes), form submissions and, in current browsers, ws:// to wss://.

What it does NOT do — and this is why it is not a solution:

  • It does not create HTTPS where there is none. If the third-party domain has no valid certificate, the upgraded request simply fails at the TLS stage. The resource is just as missing, only harder to diagnose: there is no "Mixed Content" wording in the console and no visible link to the original http URL.
  • It does not fix navigation. A user clicking an external http:// link is still unprotected.
  • It hides the problem. The console is clean, there are no reports, and http URLs keep piling up in content. A year later you have thousands of broken URLs and zero signal.
  • It has no effect in Report-Only. The directive is ignored in a report-only policy by specification.
  • It does not protect you from editors. An author pastes an http image, it is silently upgraded, and if the source has no HTTPS the reader sees nothing.

upgrade-insecure-requests is a cast, not a cure. It keeps the site usable while you fix the real URLs. Turn it on and immediately file the cleanup task plus a Report-Only policy, otherwise the cast stays on forever.

block-all-mixed-content is obsolete

This directive is still recommended in old header checklists. Do not use it:

  • It is marked obsolete in the Mixed Content specification and is not part of the current CSP directive set.
  • Current browsers ignore it or treat it as a no-op — the behaviour it used to request is already the default: active content is always blocked and passive content is upgraded.
  • Combined with upgrade-insecure-requests it is meaningless: the upgrade runs first and there is nothing left to block.
  • Its presence creates a false sense of protection during an audit: the line is there, the effect is not.

If the directive is already in your config, just delete it and verify the resulting header set with the HTTP header analyzer. A full walkthrough of a correct set is in the security headers guide.

When a third-party resource has no HTTPS

Rare, but a dead end: a widget, counter or partner storefront available over HTTP only. There is no way for a site to allow that load — browsers provide no exception for it. There are exactly four options.

  1. Demand HTTPS from the vendor. The correct path. A free certificate is issued in minutes, and the absence of HTTPS in 2026 is an oversight, not a technical constraint. Verify what they shipped with an SSL certificate check.
  2. Proxy it through your own domain. For static assets (images, JSON feeds, simple scripts) you expose an endpoint such as /proxy/partner/… that fetches the source over HTTP server-side and serves it over HTTPS. It works, but you pay for it: bandwidth, latency, caching, and — most importantly — responsibility, because as far as the browser is concerned that code is now yours with full rights in your origin. Restrict the allowed hosts and paths explicitly or you have built an SSRF proxy. For interactive iframe applications this usually fails anyway: relative URLs, cookies and framing rules break.
  3. Replace the service. Every counter, chat, map and calculator has a modern HTTPS equivalent. That is often cheaper than a proxy.
  4. Remove it. If the resource brings no measurable value, delete it and move on. There are no irreplaceable widgets.

What you must never do: tell visitors to enable "insecure content" in their browser settings. It only works for whoever enabled it, it resets on updates, and you are personally disabling protection on someone else's machine.

Special cases: iframes, fonts, favicon, sitemap and canonical

iframes

The most visible failure: instead of a map, a video or a payment form there is an empty rectangle, sometimes without even a border. There is no fallback — the browser shows no alt text and no placeholder. Check the embed code: many services served http:// snippets for years and the old markup is still sitting in old articles. Updating it usually means changing one letter in the URL.

Fonts

A web font is active content and gets blocked. The symptom is blurry: the text is readable but drawn in a system font, the metrics differ, the layout shifts, and the console error hides among the others. The right answer is almost always self-hosting: put the fonts next to your assets, reference them with a relative path, and remove a third-party domain from the critical path as a bonus.

favicon

The icon is passive content and gets upgraded silently. User impact is minimal, but an absolute http:// URL in <link rel='icon'> keeps showing up in reports and scanner output as noise. The fix takes a second: a relative /favicon.ico.

sitemap, canonical, og:image

Strictly speaking these are not mixed content: a canonical URL and sitemap entries are not loaded as page subresources. But the problem is real and it belongs to search visibility:

  • <link rel='canonical' href='http://…'> tells search engines the http version is canonical. That means one more redirect in the chain and split signals between two URLs.
  • A sitemap listing http URLs makes crawlers follow redirects instead of fetching pages, wasting crawl budget.
  • og:image with an http URL on a live HTTPS site is a classic reason link previews do not render in messengers and social networks.
  • hreflang with http URLs breaks the connection between language versions.

These fields are checked separately from mixed content, together with the rest of your technical setup. The effect of the protocol on rankings is covered in how HTTPS affects SEO, and the full switching procedure in the HTTP to HTTPS migration guide.

Relation to HSTS: after preload, errors get harsher

The HSTS header (RFC 6797) tells the browser to always use HTTPS for a given host. The upgrade happens inside the browser, before the request is sent and before the mixed content check, so HSTS effectively removes mixed content for your own domains.

The limits of that effect need to be understood precisely:

  • It only applies to hosts the browser already knows about — after a first HTTPS visit, or after inclusion in the preload list.
  • includeSubDomains extends the rule to subdomains, including the ones you forgot: an old img.example.com without a certificate becomes entirely unreachable.
  • It has no effect on third-party domains whatsoever. An http URL pointing at someone else's CDN stays mixed content.

And the main consequence: once preload is on, errors stop being soft. The browser will not let anyone reach the site over HTTP even manually, and a page with an invalid certificate is shown without a "proceed anyway" button. An expired certificate on a subdomain turns from an annoyance into a full outage. Hence the order: clean mixed content and valid certificates on every subdomain first, then HSTS with a short max-age, then a longer one, and only then preload. Details and rollout order are in the HSTS guide.

The preload list is practically irreversible: removal takes months and only reaches users with a browser update. Do not enable preload until you are sure every subdomain, including internal ones, speaks HTTPS.

Sequence: clean up mixed content, then HSTS with a short max-age, then preload
HSTS removes mixed content for your own domains but makes every certificate error hard. Rollout order matters.

Verification after the fix and regression monitoring

A one-off cleanup does not hold. A new article with an http image, a plugin update, an ad tag added by marketing in the tag manager — and a month later it is all back. You need two loops: acceptance and continuous control.

Acceptance after the fix

  1. Purge application and CDN caches. Without that you are inspecting stale HTML.
  2. Walk more than the homepage: a product page, an old article full of images, the cart, a contact form, an account page behind login.
  3. DevTools Console and Network: zero Mixed Content entries and zero (blocked:mixed-content) statuses.
  4. Run the sitemap crawl from the section above — the expected result is empty output.
  5. Confirm that Content-Security-Policy-Report-Only is served on all pages, not just the homepage.
  6. Live a week with no new violations in the reports. Only then remove upgrade-insecure-requests if you enabled it.

Defending against regression

  • Report-Only stays forever. A report-only policy breaks nothing and works as a permanent sensor. A spike in violations after a release is your signal.
  • A CI check. One pipeline step: grep the build for "http:// and ="//, fail the build on any match outside an allowlist.
  • An editor-side filter. Most CMS platforms let you hook content saving and rewrite http:// to https:// for known domains while warning the author about external http URLs.
  • A policy for third-party tags. Every script added to a tag manager is checked for HTTPS before publishing. This is the one layer that neither grep nor code review will ever catch.
  • A periodic crawl. Once a month: the sitemap crawl plus a check of the header set, so that the rest of your security headers do not drift away along with it. Easiest to automate with website monitoring.

How to check

  • Mixed content check — finds http subresources on a page and separates active from passive.
  • SSL/TLS check — certificate, chain and expiry; the place you end up after a passive upgrade fails.
  • Content-Security-Policy analysis — policy breakdown, obsolete directives, and whether the reporting endpoint is wired up.
  • HTTP headers — the actual response header set, including CSP and HSTS.
  • Security scanner — a combined page grade: headers, mixed content, TLS configuration.
  • Broken link checker — a site crawl that also surfaces http URLs and redirects.

Frequently asked questions

Is an HTTP image really dangerous?

It cannot execute code directly, which is why it is classified as passive. Indirectly the risk is real: an attacker on the network path swaps the image (a payment logo, an instruction screenshot, a QR code) and uses the request for tracking. On top of that, a modern browser will try to upgrade it to HTTPS anyway, and the image disappears if that fails — so it is also a functionality issue, not only a security one.

Can I disable the mixed content check?

Not from the site side — no such mechanism exists. A user can allow insecure content for a specific site in browser settings, but that only applies on their machine, often resets on updates, and solves nothing. Asking visitors to do it is not acceptable: you are trading their protection for your convenience.

Does upgrade-insecure-requests work for fetch and WebSocket?

Yes, the directive applies to every request initiated by the page — including fetch, XHR, form submissions and nested iframes; current browsers also upgrade ws:// to wss://. But the upgrade is useless if the target host has no HTTPS: the request fails at the TLS stage. And it has no effect on a user clicking an external link — that is HSTS territory.

I ran an UPDATE with REPLACE and some settings vanished. What happened?

You hit serialization. PHP stores strings as s:LENGTH:"value", with the length in bytes. Replacing http:// with https:// makes the string one byte longer while the prefix stays the same — unserialize() returns false and the application falls back to defaults. Restore from the dump and redo the replacement with a tool that recalculates lengths (for WordPress, wp search-replace --precise).

The console is clean but the scanner still reports mixed content. Why?

Three typical reasons. First: you have upgrade-insecure-requests enabled — the browser upgrades URLs silently while the scanner reads the raw HTML and sees http://. Second: the offending resource loads on a different page or in a different flow, for example only for signed-in users. Third: you are looking at a cached version while the scanner fetched a fresh one.

Is HSTS alone enough instead of fixing the URLs?

Only for URLs pointing at your own domains, and only after the browser has learned the policy. HSTS does not affect third-party hosts at all, and a new user's first visit is unprotected until the host is in the preload list. It is a useful additional layer, never a replacement for fixing the URLs.

How long does a full cleanup take?

It depends on the number of layers, not the number of URLs. A static landing page: an hour. A store with ten years of content, a page builder, a dozen plugins and tags in a tag manager: several days at least — and most of that time goes into finding the remaining sources and watching the reports for a week, not into the replacement itself.

Checklist

  • Violations split into active and passive — active first, they break functionality.
  • All four discovery channels used: DevTools, CSP reports from live traffic, a sitemap crawl, grep over code and the database dump.
  • Both spellings searched in the dump: http:// and the escaped http:\/\/.
  • Signed-in flows tested, not just the homepage in a private window.
  • Fixed in order: templates and assets → database → settings and caches.
  • Bulk database replacement done with a serialization-aware tool, after a dump and a dry run.
  • Protocol-relative // removed; own resources moved to root-relative paths.
  • block-all-mixed-content deleted if it was in the headers.
  • upgrade-insecure-requests enabled as a stopgap and removed after the cleanup.
  • For third-party resources without HTTPS, a deliberate choice made: vendor request, allowlisted proxy, replacement, or removal.
  • canonical, sitemap, og:image and hreflang checked for http URLs.
  • Application and CDN caches purged, sitemap and feeds regenerated.
  • Content-Security-Policy-Report-Only kept permanently as a regression sensor.
  • A CI check added, plus a policy for third-party tags.
  • HSTS and preload enabled only after mixed content is clean and every subdomain has a valid certificate.

Primary sources: W3C Mixed Content, W3C Content Security Policy Level 3, W3C Upgrade Insecure Requests, RFC 6797 (HSTS).

Check your website right now

Check your site's SSL →
More articles: SSL/TLS
SSL/TLS
SSL Certificate Chain: How to Verify and Fix an Incomplete One
15.04.2026 · 1 153 views
SSL/TLS
Expired SSL Certificate: How to Fix NET::ERR_CERT_DATE_INVALID
15.04.2026 · 1 072 views
SSL/TLS
Fix ERR_CERT_AUTHORITY_INVALID: Causes and Solutions
13.07.2026 · 1 011 views
SSL/TLS
SSL Handshake Failed: Root Causes and Step-by-Step Diagnosis
15.04.2026 · 935 views