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.
A maintenance tool actually touches a Basic-auth-protected site through two distinct paths:
browser.new_context()
→ navigation → screenshotWith 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.
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.
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.
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.
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.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.
Three principles worth keeping from this round:
(user, password)
root function. That keeps format drift from happeningsite.get(key) or ''
treats missing keys as "feature off," letting you add functionality without touching existing data. No migration needed<details>
collapse with a small icon expresses "optional feature" cleanly, and gets out of the way for everyone elseStaging 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.