Skip to content

Maintaining WordPress sites behind HTTP Basic auth — Playwright, urllib, and encrypted credentials

It’s pretty common to throw a layer of HTTP Basic auth on a WordPress site: a staging environment before launch, an internal test instance only employees should see, or any environment that wants an extra gate before the WordPress login screen itself.

From a maintenance-tool point of view, this setup creates a peculiar “half-working, half-broken” asymmetry. The SSH/WP-CLI side runs fine. But everything HTTP-based — visual checks, thumbnail generation, browser-based fallback updates — hits 401 and dies. This post walks through how we resolved that asymmetry.

What was breaking — two parallel paths, both blocked

A maintenance tool actually touches a Basic-auth-protected site through two distinct paths:

  • Playwright path: visual checks, thumbnail capture, browser fallback updates when SSH isn’t available. browser.new_context() → navigation → screenshot
  • urllib path: HTTP status checks (pre/post-update 200/5xx/4xx monitoring, rollback decisions)

With no credentials, both paths see a 401 Unauthorized from the protected site.

The Playwright symptom is the obvious one: the screenshot you save is the browser’s “authentication required” dialog. The thumbnail grid fills with dark auth-prompt images, and you start wondering whether anything actually works.

The urllib symptom is much worse — it silently breaks rollback decisions. A 401 baseline followed by another 401 after the update looks like “nothing changed = healthy.” Real failures can hide behind that match, and the rollback that should have fired never does.

The design — consolidate credential extraction into one helper

When the same credentials need to flow through multiple code paths, picking them out of the site dict separately at each call site invites format-mismatch and missed-update bugs. So the first thing we did was build a small core/basic_auth_utils.py module that owns every form of credential extraction.

# core/basic_auth_utils.py
def get_basic_auth_tuple(site):
    """Return (user, password), or None if not configured."""
    if not isinstance(site, dict):
        return None
    user = (site.get('basic_auth_user') or '').strip()
    pw = site.get('basic_auth_password') or ''
    if not user:
        return None  # No user → treat as "no auth"
    return (user, pw)

def get_playwright_http_credentials(site):
    """Returns dict for Playwright's new_context(http_credentials=...)."""
    auth = get_basic_auth_tuple(site)
    if auth is None:
        return None
    return {'username': auth[0], 'password': auth[1]}

def get_basic_auth_header(site):
    """Returns {'Authorization': 'Basic <base64>'} for urllib."""
    auth = get_basic_auth_tuple(site)
    if auth is None:
        return {}
    raw = f"{auth[0]}:{auth[1]}".encode('utf-8')
    encoded = base64.b64encode(raw).decode('ascii')
    return {'Authorization': f'Basic {encoded}'}

The key idea: both the Playwright and urllib forms derive from the same get_basic_auth_tuple() root. Format drift between the two callers becomes structurally impossible.

Backward compatibility — existing sites don’t have these fields

A small but important detail: the existing site-configuration JSON has no basic_auth_user / basic_auth_password keys. Naively writing site['basic_auth_user'] would crash with KeyError the moment someone opens an existing site.

We went with the site.get('basic_auth_user') or '' empty-string-fallback pattern. Missing key, empty string, or None all collapse to “no auth,” so existing sites behave exactly as before. Only the sites that actually set Basic auth flip into the authenticated path.

On top of that, the Basic auth password gets the same treatment as the WordPress admin password: Fernet-encrypted at rest. Adding 'basic_auth_password' to the ENCRYPTED_SITE_KEYS constant is all it takes — encryption and decryption happen automatically on save and load.

Wiring both paths

With the helpers in place, the call sites get rewired.

Playwright path: one shared helper _new_context_with_auth(browser, site) replaces three new_context() call sites (visual check, thumbnail capture, browser residual update) at once.

def _new_context_with_auth(browser, site):
    http_credentials = get_playwright_http_credentials(site)
    if http_credentials:
        return browser.new_context(http_credentials=http_credentials)
    return browser.new_context()

urllib path: _http_status_check(url, basic_auth=None) gains a basic_auth parameter and sends the Authorization header internally. The “baseline 401 → post-update 401” false negative disappears — the rollback decision now sees the real status code after authentication (200 / 5xx / etc.) and fires correctly.

Inside the maintenance main loop run_ssh_maintenance, we extract _basic_auth from the site dict exactly once and pass it into all five _http_status_check_stable() calls. Pulling it out from the dict at every call site invites “this one place forgot,” so a local variable is the safer move.

UI — keep the rare case out of the way

From a user perspective, Basic-auth-protected sites are the minority. Putting two always-visible input fields in the site-add modal would clutter the UI for the 99% of sites that don’t need them.

So we put the credentials behind a <details> element — a collapsed-by-default “🔐 Basic Auth (optional)” section at the end of the WordPress info group in the site-add/edit modal.

<details>
  <summary>🔐 Basic Auth (optional)</summary>
  <input name="basic_auth_user" placeholder="Auth username">
  <input type="password" name="basic_auth_password"
         placeholder="Auth password">
</details>

Users with Basic-auth-protected sites expand and fill it in; the rest see no change. Saved passwords go through Fernet with the ENC: prefix.

Closing — patterns for “same credentials, multiple paths”

Three principles worth keeping from this round:

  1. When multiple paths need the same credentials, consolidate extraction into one helper. Playwright form, urllib form, boolean check — each consumer wants a different shape, but they should all derive from a single (user, password) root function. That keeps format drift from happening
  2. For new fields on existing data, lean on empty-fallback patterns. site.get(key) or '' treats missing keys as “feature off,” letting you add functionality without touching existing data. No migration needed
  3. Hide rare features behind disclosure widgets. A field used by 1% of sites doesn’t deserve top-billing visual space. A <details> collapse with a small icon expresses “optional feature” cleanly, and gets out of the way for everyone else

Staging environments behind Basic auth aren’t rare in the WordPress world. If your automation tool is “half working” against those sites, teams quickly fall back to manual maintenance just for that subset. Designing a clean credential path through both the Playwright and urllib sides — once — is worth the up-front investment.