DEV Community

Why your small business WordPress site says "Not secure" (and a small Python script that checks for it)

Most small business sites I look at don't have exotic problems. They have the same four boring ones:

  1. The TLS certificate is expired or about to expire, so browsers show a full-page warning.
  2. http:// doesn't redirect to https://, so old links and some search results land on the insecure version.
  3. The page is HTTPS but still loads images, scripts or stylesheets over http:// (mixed content). Browsers upgrade or block them, and the padlock can disappear.
  4. There's no responsive viewport tag, so phones render a tiny zoomed-out desktop page.

All four can be seen from the outside with a normal page load, so you can check them without any access to the site. Here's a small script that does exactly that, and then the WordPress fix for each one.

The script

Standard library only, so it runs anywhere Python 3.8+ is installed. It loads the homepage like a browser: no scanning, no guessing paths, no logins.

#!/usr/bin/env python3
"""site_check.py - a quick, polite HTTPS + mobile sanity check for a small business site.

Standard library only (Python 3.8+). It loads the homepage like a normal browser
would: no port scans, no login attempts, no path guessing.

Usage:  python3 site_check.py example.com [another-site.co.uk ...]
"""
import socket
import ssl
import sys
import urllib.error
import urllib.request
from datetime import datetime, timezone
from html.parser import HTMLParser

UA = "Mozilla/5.0 (site_check.py; quick public health check)"
TIMEOUT = 15


class PageScan(HTMLParser):
    """Collects the viewport tag and any http:// sub-resources on the page."""

    # tag -> attributes that make the browser fetch something
    RESOURCE_ATTRS = {
        "img": ("src", "srcset"), "script": ("src",), "iframe": ("src",),
        "source": ("src", "srcset"), "video": ("src", "poster"),
        "audio": ("src",), "embed": ("src",), "object": ("data",),
    }

    def __init__(self):
        super().__init__()
        self.viewport = None
        self.insecure = []

    def handle_starttag(self, tag, attrs):
        a = {k.lower(): (v or "") for k, v in attrs}
        if tag == "meta" and a.get("name", "").lower() == "viewport":
            self.viewport = a.get("content", "")
        if tag == "link" and "stylesheet" in a.get("rel", "").lower():
            self._check(tag, a.get("href", ""))
        for attr in self.RESOURCE_ATTRS.get(tag, ()):
            self._check(tag, a.get(attr, ""))

    def _check(self, tag, value):
        # srcset can hold several URLs: "a.jpg 1x, b.jpg 2x"
        for part in value.split(","):
            url = part.strip().split(" ")[0]
            if url.lower().startswith("http://"):
                self.insecure.append(f"<{tag}> {url}")


def cert_days_left(host):
    """Days until the TLS certificate expires, or the error string if the handshake fails."""
    ctx = ssl.create_default_context()
    try:
        with socket.create_connection((host, 443), timeout=TIMEOUT) as sock:
            with ctx.wrap_socket(sock, server_hostname=host) as tls:
                not_after = tls.getpeercert()["notAfter"]
    except ssl.SSLCertVerificationError as e:
        return f"certificate problem: {e.verify_message}"
    except (OSError, ssl.SSLError) as e:
        return f"no HTTPS connection: {e}"
    expires = datetime.fromtimestamp(ssl.cert_time_to_seconds(not_after), timezone.utc)
    return (expires - datetime.now(timezone.utc)).days


def fetch(url):
    """Follow redirects like a browser. Returns (final_url, status, html)."""
    req = urllib.request.Request(url, headers={"User-Agent": UA})
    try:
        with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
            return r.geturl(), r.status, r.read(2_000_000).decode("utf-8", "replace")
    except urllib.error.HTTPError as e:
        return e.geturl(), e.code, ""


def check(host):
    host = host.replace("https://", "").replace("http://", "").strip("/")
    print(f"\n== {host}")
    problems = 0

    # 1. Does the certificate validate, and when does it expire?
    days = cert_days_left(host)
    if isinstance(days, int):
        flag = "OK" if days > 14 else "WARN"
        problems += flag != "OK"
        print(f"  [{flag}] certificate valid, expires in {days} days")
    else:
        problems += 1
        print(f"  [FAIL] {days}")

    # 2. Does plain http:// send visitors to https://?
    try:
        final, status, _ = fetch(f"http://{host}/")
        if final.startswith("https://"):
            print(f"  [OK] http:// redirects to {final}")
        else:
            problems += 1
            print(f"  [FAIL] http:// stays on {final} (status {status}), no redirect to HTTPS")
    except Exception as e:  # noqa: BLE001 - report, don't crash on one bad site
        print(f"  [INFO] http:// check skipped: {e}")

    # 3. Mixed content and viewport on the HTTPS homepage
    try:
        final, status, html = fetch(f"https://{host}/")
    except Exception as e:  # noqa: BLE001
        print(f"  [FAIL] could not load https://{host}/ ({e})")
        return problems + 1
    scan = PageScan()
    scan.feed(html)
    if scan.insecure:
        problems += 1
        print(f"  [FAIL] {len(scan.insecure)} http:// resource(s) on an HTTPS page (mixed content):")
        for item in scan.insecure[:5]:
            print(f"         {item}")
    else:
        print("  [OK] no http:// images, scripts or stylesheets found in the HTML")
    if scan.viewport and "width=device-width" in scan.viewport.replace(" ", ""):
        print(f'  [OK] viewport: "{scan.viewport}"')
    else:
        problems += 1
        print("  [FAIL] no responsive viewport tag, so phones will show a zoomed-out desktop page")
    return problems


if __name__ == "__main__":
    if len(sys.argv) < 2:
        sys.exit(__doc__)
    total = sum(check(h) for h in sys.argv[1:])
    print(f"\n{total} problem(s) found")
    sys.exit(1 if total else 0)
Enter fullscreen mode Exit fullscreen mode

Testing it

badssl.com runs deliberately broken test hosts, which makes it a good way to see each check fire:

python3 site_check.py badssl.com expired.badssl.com mixed.badssl.com http.badssl.com wordpress.org
Enter fullscreen mode Exit fullscreen mode
== badssl.com
  [OK] certificate valid, expires in 74 days
  [OK] http:// redirects to https://badssl.com/
  [OK] no http:// images, scripts or stylesheets found in the HTML
  [OK] viewport: "width=device-width, initial-scale=1.0"

== expired.badssl.com
  [FAIL] certificate problem: certificate has expired
  [INFO] http:// check skipped: <urlopen error [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:1010)>
  [FAIL] could not load https://expired.badssl.com/ (<urlopen error [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:1010)>)

== mixed.badssl.com
  [OK] certificate valid, expires in 74 days
  [OK] http:// redirects to https://mixed.badssl.com/
  [FAIL] 1 http:// resource(s) on an HTTPS page (mixed content):
         <img> http://mixed.badssl.com/image.jpg
  [OK] viewport: "width=device-width, initial-scale=1"

== http.badssl.com
  [OK] certificate valid, expires in 74 days
  [FAIL] http:// stays on http://http.badssl.com/ (status 200), no redirect to HTTPS
  [OK] no http:// images, scripts or stylesheets found in the HTML
  [OK] viewport: "width=device-width, initial-scale=1"

== wordpress.org
  [OK] certificate valid, expires in 68 days
  [OK] http:// redirects to https://wordpress.org/
  [OK] no http:// images, scripts or stylesheets found in the HTML
  [OK] viewport: "width=device-width, initial-scale=1"

4 problem(s) found
Enter fullscreen mode Exit fullscreen mode

The expired certificate, the missing redirect on http.badssl.com and the insecure image on mixed.badssl.com are all caught; the healthy sites pass. The exit code is 1 when anything fails, so you can drop it into a cron job or CI step for the sites you look after.

Fixing each one on WordPress

Always take a backup first (your host's backup tool, or wp db export plus a copy of wp-content).

1. Expired or failing certificate

On most shared hosting the certificate is a free Let's Encrypt one that renews automatically, and an expired one usually means the renewal broke: the domain's DNS moved, or a CDN/proxy sits in front of the site. Look in the host's SSL/TLS panel first and re-issue there. If the site is behind Cloudflare, check that its SSL mode matches what the origin actually serves.

2. No redirect from http:// to https://

First make sure WordPress itself knows its address is HTTPS: Settings → General, set both WordPress Address (URL) and Site Address (URL) to https://.... Then add a server-level 301. On Apache, near the top of .htaccess (above the # BEGIN WordPress block):

RewriteEngine On
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://%{HTTP_HOST}/$1 [L,R=301]
Enter fullscreen mode Exit fullscreen mode

One trap: behind a proxy or CDN, %{HTTPS} can be off even for HTTPS visitors, which gives you a redirect loop. In that case do the redirect at the CDN (Cloudflare's "Always Use HTTPS") instead of in .htaccess. On Nginx it's a return 301 https://$host$request_uri; in the port-80 server block.

It's also worth adding this to wp-config.php so the dashboard is always served over HTTPS:

define( 'FORCE_SSL_ADMIN', true );
Enter fullscreen mode Exit fullscreen mode

3. Mixed content

This almost always comes from old absolute URLs saved in the database (post content, widgets, page-builder data) from before the site moved to HTTPS. With WP-CLI, do a dry run first:

wp search-replace 'http://example.com' 'https://example.com' --all-tables --dry-run
wp search-replace 'http://example.com' 'https://example.com' --all-tables
Enter fullscreen mode Exit fullscreen mode

wp search-replace handles PHP-serialized data, which a raw SQL REPLACE() would corrupt. Some page builders keep generated CSS files with the old URLs, so regenerate those afterwards (in Elementor: Tools → Regenerate CSS & Data). Anything still left is usually hard-coded in a theme or plugin file, or comes from a third-party embed that only supports http://; replace or remove it.

The script only reads the homepage HTML, so it won't see URLs inside CSS files or resources injected by JavaScript. For the full list, open the browser DevTools console: mixed content warnings name each URL.

4. No viewport tag

Any reasonably modern theme includes it, so a missing viewport usually means a very old or heavily customised theme. The real fix is a responsive theme, because the tag alone won't make a fixed-width layout fit a phone. If the layout already uses responsive CSS and only the tag is missing, add it from a child theme's functions.php:

add_action( 'wp_head', function () {
    echo '<meta name="viewport" content="width=device-width, initial-scale=1">' . "\n";
}, 1 );
Enter fullscreen mode Exit fullscreen mode

Check the page source afterwards so you don't end up with two viewport tags.

Wrapping up

None of this is hard, but it's easy to miss when nobody is looking after the site day to day. Running a check like this monthly catches a certificate that's about to lapse before customers see the warning.


I'm Shanmukh, a student and solo developer. I fix exactly these issues (HTTPS, mobile layout, forms) on small business sites, and build simple 5-page sites, as fixed-scope freelance work. If you'd rather hand it off, the packages are on Fiverr and Whop. More code is on GitHub.

Top comments (0)