{
  "$type": "site.standard.document",
  "bskyPostRef": {
    "cid": "bafyreicx3b6hevjsih5himolssf7yioa7str6pemzoipma7xpluflgr7c4",
    "uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mpkg5po44ru2"
  },
  "coverImage": {
    "$type": "blob",
    "ref": {
      "$link": "bafkreibyp7vnds622uvdvsc4ojsqdamacpcwuovu6aqwwfew4cdfxur4fm"
    },
    "mimeType": "image/webp",
    "size": 93280
  },
  "path": "/susumun/maintaining-wordpress-sites-behind-http-basic-auth-playwright-urllib-and-encrypted-credentials-5f2f",
  "publishedAt": "2026-07-01T00:56:50.000Z",
  "site": "https://dev.to",
  "tags": [
    "wordpress",
    "php",
    "tutorial"
  ],
  "textContent": "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.\n\nFrom 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.\n\n##  What was breaking — two parallel paths, both blocked\n\nA maintenance tool actually touches a Basic-auth-protected site through two distinct paths:\n\n  * **Playwright path** : visual checks, thumbnail capture, browser fallback updates when SSH isn't available. `browser.new_context()` → navigation → screenshot\n  * **urllib path** : HTTP status checks (pre/post-update 200/5xx/4xx monitoring, rollback decisions)\n\n\n\nWith no credentials, both paths see a **401 Unauthorized** from the protected site.\n\nThe 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.\n\nThe 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.\n\n##  The design — consolidate credential extraction into one helper\n\nWhen 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**.\n\n\n\n    # core/basic_auth_utils.py\n    def get_basic_auth_tuple(site):\n        \"\"\"Return (user, password), or None if not configured.\"\"\"\n        if not isinstance(site, dict):\n            return None\n        user = (site.get('basic_auth_user') or '').strip()\n        pw = site.get('basic_auth_password') or ''\n        if not user:\n            return None  # No user → treat as \"no auth\"\n        return (user, pw)\n\n    def get_playwright_http_credentials(site):\n        \"\"\"Returns dict for Playwright's new_context(http_credentials=...).\"\"\"\n        auth = get_basic_auth_tuple(site)\n        if auth is None:\n            return None\n        return {'username': auth[0], 'password': auth[1]}\n\n    def get_basic_auth_header(site):\n        \"\"\"Returns {'Authorization': 'Basic <base64>'} for urllib.\"\"\"\n        auth = get_basic_auth_tuple(site)\n        if auth is None:\n            return {}\n        raw = f\"{auth[0]}:{auth[1]}\".encode('utf-8')\n        encoded = base64.b64encode(raw).decode('ascii')\n        return {'Authorization': f'Basic {encoded}'}\n\n\nThe 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.\n\n##  Backward compatibility — existing sites don't have these fields\n\nA 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.\n\nWe 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.\n\nOn 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.\n\n##  Wiring both paths\n\nWith the helpers in place, the call sites get rewired.\n\n**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.\n\n\n\n    def _new_context_with_auth(browser, site):\n        http_credentials = get_playwright_http_credentials(site)\n        if http_credentials:\n            return browser.new_context(http_credentials=http_credentials)\n        return browser.new_context()\n\n\n**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.\n\nInside 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.\n\n##  UI — keep the rare case out of the way\n\nFrom 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.\n\nSo 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.\n\n\n\n    <details>\n      <summary>🔐 Basic Auth (optional)</summary>\n      <input name=\"basic_auth_user\" placeholder=\"Auth username\">\n      <input type=\"password\" name=\"basic_auth_password\"\n             placeholder=\"Auth password\">\n    </details>\n\n\nUsers with Basic-auth-protected sites expand and fill it in; the rest see no change. Saved passwords go through Fernet with the `ENC:` prefix.\n\n##  Closing — patterns for \"same credentials, multiple paths\"\n\nThree principles worth keeping from this round:\n\n  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\n  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\n  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\n\n\n\nStaging 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.",
  "title": "Maintaining WordPress sites behind HTTP Basic auth — Playwright, urllib, and encrypted credentials"
}