Skip to content
Server & .htaccess

WordPress Mixed Content After Moving to HTTPS

Clear WordPress mixed content warnings after an HTTPS move: find the http:// URLs left in the database, replace them safely, and force HTTPS in .htaccess.

Published

Your certificate is installed, the site loads over HTTPS, and the padlock still refuses to appear. That is mixed content: the page itself came over HTTPS, but something inside it — an image, a stylesheet, a script — is still being requested over plain http://. This walks through finding those URLs, replacing them without corrupting serialized data, correcting siteurl and home, and forcing HTTPS at the server so the problem cannot come back.

What mixed content actually is

Installing a certificate changes how the connection is encrypted. It does not change what your pages ask for. Every http://yoursite.com/wp-content/uploads/logo.png that was written into a post, a widget, or a theme setting before the move is still sitting in the database, and the browser dutifully requests it over an insecure connection.

Browsers treat two categories differently, and the distinction matters when you’re deciding how urgent this is:

  • Active mixed content — scripts, stylesheets, iframes, XHR. Blocked outright. This is why a site can look broken after an HTTPS move with no error message anywhere: a stylesheet was silently refused.
  • Passive mixed content — images, audio, video. Usually still loaded, but the padlock is downgraded and some browsers show a “not secure” indicator.

Both are worth fixing. Only the first one breaks things.

Step 1: find out what is still insecure

Start in the browser. Open the page, open developer tools, and read the console. Every blocked or downgraded request is named there with its full URL. That tells you what is insecure; it does not tell you where it is stored.

For that, look at the database directly. Export it and search the dump:

wp db export dump.sql
grep -o "http://example\.com[^\"']*" dump.sql | sort -u | head -50

If WP-CLI is not available, mysqldump produces the same file:

mysqldump -u USER -p DBNAME > dump.sql

Read the unique URLs that come back. Uploads paths point at post content and meta. Theme asset paths usually point at options. Anything with a domain you do not recognise is an external embed, which needs a different fix (covered below).

Step 2: fix siteurl and home first

siteurl and home are the two options WordPress uses to build almost every internal URL it generates. If either still says http://, WordPress will keep emitting insecure URLs no matter how clean the rest of the database is.

Check them:

wp option get siteurl
wp option get home

Or in SQL:

SELECT option_name, option_value
FROM wp_options
WHERE option_name IN ('siteurl', 'home');

These two are plain strings, not serialized arrays, so a direct SQL update is genuinely safe here:

UPDATE wp_options
SET option_value = REPLACE(option_value, 'http://example.com', 'https://example.com')
WHERE option_name IN ('siteurl', 'home');

One trap before you spend time on this. If wp-config.php defines WP_HOME or WP_SITEURL, those constants override the database completely and your update will appear to do nothing:

define( 'WP_HOME', 'https://example.com' );
define( 'WP_SITEURL', 'https://example.com' );

Update them there, or remove them and let the database drive. Check this first — it explains a lot of “I changed it and nothing happened” cases.

Step 3: the replace has to be serialization-safe

Everything else in the database is where the real risk sits, and it is not the risk most people expect. The danger is not that the replace misses URLs. It is that it succeeds at the text level and destroys the surrounding data.

WordPress stores arrays and objects as PHP-serialized strings, and that format records the byte length of every string it contains:

a:1:{s:4:"logo";s:38:"http://example.com/img/logo.png";}

Change http:// to https:// with a raw SQL REPLACE and the text becomes one byte longer while the declared length stays at the old value. PHP reads the length, walks that many bytes forward, does not find the terminator where it expects it, and refuses to unserialize the whole array. WordPress then hands the theme or plugin a value that behaves as if the setting was never saved.

The symptom is not an error. It is widgets vanishing, customizer settings resetting, and page builder layouts rendering blank — with nothing in the admin indicating anything went wrong. Recovery from that without a backup is genuinely painful, so take one:

wp db export backup-before-https-replace.sql

Then use a tool that unserializes, replaces inside the decoded structure, and re-serializes with corrected lengths. Dry run first:

wp search-replace 'http://example.com' 'https://example.com' --dry-run --all-tables --skip-columns=guid

Read the report. If a table you do not recognise shows thousands of hits, stop and look before committing. Then run it for real:

wp search-replace 'http://example.com' 'https://example.com' --all-tables --skip-columns=guid --report-changed-only

--skip-columns=guid matters: the guid is a permanent identifier for feed readers, not a live URL, and rewriting it can make subscribers see your entire archive as new posts. If you need to see which columns are safe for plain SQL and which ones require serialization-aware handling before you run anything, build the statements with the WordPress search and replace SQL tool.

If you cannot use the command line, a migration plugin that explicitly states it handles serialized data does the same decoding work through the admin screen. If a tool does not say so, assume it does not.

Step 4: force HTTPS at the server

Cleaning the database stops your pages from requesting insecure resources. It does not stop a visitor from arriving on http:// in the first place. That is a server-level redirect, and on Apache it belongs in .htaccess:

# BEGIN Force HTTPS
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{HTTPS} !=on
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
</IfModule>
# END Force HTTPS

Two things about placement. Put this above the # BEGIN WordPress markers — anything inside them is discarded every time someone saves permalinks. And this only works if mod_rewrite is enabled and the virtual host allows overrides (AllowOverride All, or at least FileInfo). On nginx, .htaccess is ignored entirely and this needs to go in a server block instead.

A RedirectMatch will not do this job. It matches on the request path only and has no way to test whether the current request is already secure, so it redirects HTTPS requests back to themselves. The conditional RewriteRule above is the right tool.

The redirect loop, and why it happens

If the site starts redirecting forever the moment you add that rule, your TLS is terminating somewhere upstream — a load balancer, a reverse proxy, or a CDN — which then forwards plain HTTP to Apache. Apache sees an insecure request, redirects to HTTPS, the proxy forwards HTTP again, and around it goes.

Test the forwarded header instead:

RewriteCond %{HTTP:X-Forwarded-Proto} !https
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]

WordPress itself has the same blind spot in that setup — is_ssl() reads $_SERVER['HTTPS'], which the proxy never sets, so admin URLs come out as http://. Add this to wp-config.php, above the “stop editing” line:

if ( isset( $_SERVER['HTTP_X_FORWARDED_PROTO'] ) && 'https' === $_SERVER['HTTP_X_FORWARDED_PROTO'] ) {
	$_SERVER['HTTPS'] = 'on';
}

Worth being straight about the risk: X-Forwarded-Proto is a client-supplied header. Trusting it is correct only when a proxy you control always overwrites it. On a server reachable directly from the internet, it can be spoofed.

What a database replace will not fix

  • Hardcoded URLs in files. Anything written into functions.php, a child theme template, or an inline asset path is not in the database. Grep the theme directory separately.
  • Escaped JSON. Some builders store URLs as https:\/\/example.com. A search for the normal form misses them entirely, so a second pass on the escaped string may be needed.
  • External resources. A third-party script or embed only available over HTTP cannot be fixed from your side. Find an HTTPS version or drop it.
  • Caches. Page cache, object cache, and CDN copies keep serving the old markup after a correct replace. Flush all three before concluding the replace failed.

One thing worth skipping: the upgrade-insecure-requests Content Security Policy header will silence the warnings by rewriting insecure requests in the browser. It treats the symptom and leaves the wrong URLs in your database, where the next export, migration, or feed will carry them forward. Fix the data, then use it as a safety net if you want it.

Still stuck?

Re-check siteurl and home after every step — plugins and migration tools sometimes rewrite them behind you. If the console still names an insecure URL you cannot find in the dump, view the page source and search for it there: if it appears in generated markup but not in the database, it is being built in PHP, and the theme or plugin producing it is the thing to fix.

FAQ

Questions

Why does my WordPress site still show mixed content warnings after installing an SSL certificate?

The certificate only changes how the connection is encrypted, not what your pages ask for. Old http:// URLs stay stored in the database, inside post content, widget options and theme settings. The browser loads the page over HTTPS, sees an image or script being requested over plain HTTP, and reports mixed content on an otherwise secure page.

How do I find the http:// URLs still in my WordPress database?

Open a page in a browser and read the developer console, which names every insecure request by URL. Then export the database and search the dump for your domain with the http prefix. Counting the hits per table tells you whether the problem lives in post content, in wp_options, or in plugin tables you did not expect.

Can I fix mixed content with a plain SQL search and replace?

Only for scalar values such as siteurl and home. Anything WordPress stores as a serialized array, which covers most option and meta values, records the byte length of every string inside it. A raw replace changes the text but leaves the old length, the value then fails to unserialize, and the setting silently reverts to its default.

What .htaccess rule forces HTTPS in WordPress?

A RewriteRule guarded by a condition on the HTTPS variable, placed above the BEGIN WordPress markers so that saving permalinks does not wipe it. Behind a proxy or CDN the HTTPS variable reads as off even on secure requests, so test the X-Forwarded-Proto header instead or the rule will redirect forever.

Why did my site go into a redirect loop after forcing HTTPS?

Your TLS is almost certainly terminating at a proxy or CDN that then forwards plain HTTP to Apache. The rewrite sees an insecure request, redirects to HTTPS, the proxy forwards HTTP again, and the loop repeats. Switch the condition to the X-Forwarded-Proto header and set the HTTPS server variable in wp-config.php.

Does mixed content break the whole page or just the padlock?

It depends on the resource. Browsers block active mixed content such as scripts, stylesheets and iframes outright, which can break layout or functionality with no visible explanation. Passive mixed content such as images and video usually still loads, but the padlock is downgraded and visitors may see a not secure indicator. Both are worth fixing.