What the browser is actually complaining about
http://. Subresources are the images, scripts, stylesheets, fonts, iframes, and AJAX calls that a page loads after the HTML. The certificate is fine. The page is just asking for some of its pieces over an unencrypted connection.
Browsers sort mixed content into two groups, and they treat the groups differently:
- Passive content: images, audio, and video. These can't change the rest of the page. Modern browsers typically try to upgrade them to HTTPS automatically, and they show a warning or drop the resource if that fails.
- Active content: scripts, stylesheets, iframes, and fetch or XHR requests. These can rewrite the page, so browsers block them outright.
Tracing the http:// URL back to its source
- The
homeandsiteurloptions still sayhttp://. In that case almost every URL WordPress generates is wrong. - Post and page content. The editor saves absolute image URLs into
post_content, so every image inserted before the move still points athttp://. - Options and postmeta. Theme customizer settings, widgets, and page builder data often store absolute URLs, frequently as serialized PHP arrays.
- Theme or plugin files. Hardcoded
http://in a template, or aurl(http://...)inside a CSS file. - Third-party resources. An embed, a font, or a tracking script from another domain.
is_ssl() is false and generates http:// URLs even when the database is correct. This shows up often after a move behind a proxy, and on the support desk it's one of the first things worth ruling out. A search-replace won't help here.
Start with read-only checks. WP-CLI makes this quick, and --dry-run reports matches per table without writing anything:
BASH
# What WordPress thinks its own address is
wp option get home
wp option get siteurl
# Count stored http:// references to your own domain, without changing anything
wp search-replace 'http://example.com' 'https://example.com' --all-tables-with-prefix --dry-run
# Hardcoded http:// in the active theme's PHP and CSS files
grep -rn 'http://' wp-content/themes/your-theme --include='*.php' --include='*.css'- If
homeandsiteurlare alreadyhttps://, the dry run finds almost nothing, and the page source is still full ofhttp://links to your own domain, suspect HTTPS detection. Confirm by checking whether the generated URLs change depending on how you reach the site, for example directly on the origin versus through the CDN. - If the dry run reports matches in
wp_postsandwp_postmeta, the source is stored content. - If the grep finds hits, the theme is involved.
- If the console URLs belong to someone else's domain, none of the above applies. Either that host supports HTTPS and you change the embed, or it doesn't and the resource has to go.
http:// links for a while after you've fixed the source. If you clear your browser cache but not the server cache, a correct fix will look like it failed.
Fixing it at the source with search-replace
UPDATE ... REPLACE() changes http to https, the string becomes one character longer, the recorded length no longer matches, and PHP can't read the value anymore. The visible result is widgets, theme settings, or page builder layouts that quietly reset. WP-CLI's search-replace unserializes the data, replaces the text, and serializes it again with correct lengths.
Take a database export before writing anything. This is the one step in the process that is hard to undo without a backup:
BASH
# 1. Back up first
wp db export before-https-replace.sql
# 2. Preview again, skipping the guid column
wp search-replace 'http://example.com' 'https://example.com' --all-tables-with-prefix --skip-columns=guid --dry-run
# 3. Run for real only after the preview looks right
wp search-replace 'http://example.com' 'https://example.com' --all-tables-with-prefix --skip-columns=guid
# 4. Clear the object cache so stale values are not served
wp cache flushguid column is skipped by convention. It acts as a permanent identifier for posts, especially in feeds, and isn't meant to be rewritten as a link. Include the www or non-www variant of your domain if both were ever used, and run a separate pass for each. After the replace, purge the page cache and the CDN cache too.
For theme files, fix the code directly. In templates, use WordPress functions such as home_url() or get_template_directory_uri() instead of hardcoded addresses. In CSS, use HTTPS or relative paths. If it's a third-party theme, a theme update or a child theme override keeps your change from being overwritten later.
For the proxy case, tell WordPress the original request was HTTPS. A common approach is a short block in wp-config.php, above the line that loads wp-settings.php. It checks the forwarded protocol header and sets $_SERVER['HTTPS'] = 'on' when the value is https. The header name depends on your proxy or CDN. Only trust it if your proxy actually sets it and clients can't reach the origin directly, because anyone can send that header themselves.
Where upgrade-insecure-requests fits
upgrade-insecure-requests is a Content-Security-Policy directive. It tells the browser to rewrite http:// subresource URLs to https:// before fetching them. It's a native browser feature with no PHP involved and no output rewriting, which makes it a useful safety net.
NGINX
# Inside the HTTPS server block
add_header Content-Security-Policy 'upgrade-insecure-requests' always;.htaccess or the vhost is Header always set Content-Security-Policy 'upgrade-insecure-requests', which needs mod_headers enabled. In Nginx, if a location block defines its own add_header directives, it stops inheriting the server-level ones, so the header can disappear on some paths. Check with curl -sI https://example.com/ and look for the header in the response.
Its limits matter more than its convenience:
- It doesn't fall back. If the HTTPS version of a resource doesn't exist, the resource just fails. A third-party host without HTTPS stays broken, only now the failure is silent.
- It only affects browsers that honor the policy. Feeds, API consumers, emails, and link preview scrapers still receive
http://URLs. - It's ignored in a
Content-Security-Policy-Report-Onlyheader, so you can't trial it in report-only mode. - It hides the data problem. The database stays dirty, and if the header disappears later during a server move or config rewrite, the mixed content comes back.
http:// to https:// before it's sent. The output rewrite costs some processing on every uncached request. Like the header, it only fixes what goes out through that one path. A typical support pattern: the padlock turns clean after a plugin is installed, then breaks again months later when the plugin is deactivated during troubleshooting, and nobody remembers why it was there.
Picking the fix by source
| Where the http:// URL comes from | Durable fix | Is the header or plugin enough? |
|---|---|---|
home / siteurl options | Update both to https:// | No |
| Post content, postmeta, options | WP-CLI search-replace after a backup | Only as a temporary bridge |
| Theme or plugin files | Fix the code or update the theme | As a stopgap |
| WordPress not detecting HTTPS behind a proxy | Pass the forwarded protocol in wp-config.php | No, URLs keep being generated wrong |
| Third-party resource without HTTPS | Replace or remove it | No, the upgrade just fails |
http://. A clean console with http:// still in the source means the header is covering for you, not that the source is fixed.
The practical rule: fix the source first with a backed-up WP-CLI search-replace, theme fixes, or correct proxy detection. Then add upgrade-insecure-requests if you want a safety net for stray URLs you didn't catch. Treat the header and plugins as a backstop, not as the fix, so the padlock doesn't depend on one header line or one plugin staying in place.