Password-protect HTML publishing by default

2026-09-10 · implementation plan for the html-page / html-plan / html-handoff skill stack · generated by Codex

Summary

Add a server-side, per-page authentication gate to Cloudflare Pages. Keep the combined index and pages.json public so titles, dates, links, and cross-machine search continue to work. A new page uses the supplied password=<value>; an omitted or empty password causes the script to generate a unique strong random password; access=public explicitly opts that page out of protection. Browsers get a normal password prompt. Any agent given only the link and password can read the page with standard curl and no local setup. Missing security configuration fails closed.

This is materially safer than unguessable URLs, but it is still shared-secret protection rather than individual identity. Cloudflare recommends Access for production-grade identity controls; the proposed first version deliberately favors the requested password experience and easy agent access.

Key decisions

AreaDecisionReason
Enforcementdefault Root _worker.js in the published archiveIt runs at Cloudflare before static assets are returned and works with the existing Wrangler direct-upload workflow. Cloudflare documents that advanced-mode workers take control of all requests and delegate allowed assets through env.ASSETS.fetch().
Skill inputpassword=<value>, empty/omitted password, or access=publicCodex skills are instruction bundles invoked with free-form prompt text, not tools with a typed argument schema. These are therefore explicit invocation conventions which the skill translates into safe script flags. Official OpenAI documentation shows skill invocation with trailing prompt text and recommends explicit inputs and outputs.
Public surfaceIndex resources are always public; page bodies are public only when their registry entry says access=publicThe local and cross-machine index remains usable. Public pages are a deliberate per-page opt-out, never the result of a missing password.
Human authPer-page HTTP Basic Auth over HTTPS, fixed username pageEach plan, page, or handoff can use a desired or generated password without rotating credentials for older URLs.
Agent accessStandard HTTP Basic Auth with fixed username pageA URL plus its password is sufficient on any computer: curl --user "page:PASSWORD" URL. No skill installation, shared token, synchronized registry, or Cloudflare account is required.
Password registryRestricted local registry plus a generated worker route-policy map containing salted password verifiersThe worker can enforce different policies per URL without publishing plaintext passwords as static assets. This avoids a separate pepper secret and multi-machine provisioning protocol.
CachingCache-Control: private, no-store on protected responsesReduces leakage from browser and intermediary caches after authentication.
Free-tier routingExclude only the public index resources from Function invocation; route every page body through the workerPublic index traffic stays on unlimited static serving, while protected bodies consume the Workers Free quota. Configure Pages Functions to fail closed if that quota is exhausted.
Failure moderequired A page without a valid registry entry returns 503; never infer public accessA missing password, corrupt registry, or failed map generation must make content unavailable rather than public.

Primary references: Pages advanced mode, direct upload and _worker.js, Cloudflare’s Basic Auth example and cautions, and OpenAI’s Codex skill invocation and authoring guidance.

Scope control after adversarial Astra audit

ClassificationWork
MVPPer-page registry; unique desired/random password contract; explicit public opt-out; public index; edge gate; link+password interoperability with standard curl; fail-closed tests; single-project rollout.
securityAudit old deployment aliases once, replace undeletable branch-head deployments with a gated version, and test old URLs before claiming legacy content is protected.
deferCurl wrapper, site-wide bearer token, HMAC pepper lifecycle, capacity alerts, automated retention/cleanup, custom logout endpoint, authenticated ngrok fallback, and automatic registry synchronization. Cross-computer sharing uses the unique page password directly.
acceptedThe simplest receiving-agent command may contain the page-specific password in its process arguments. This is acceptable because the password is intentionally shared with that agent and grants only one page; interactive curl remains available when process visibility matters.

Phases

Phase 1 — Build and test the edge gate medium
  • Add an auth-worker source asset under html-page/assets/; have deploy.sh copy it to ~/.claude/html-plans/_worker.js before every deployment.
  • Generate a route-policy map for every archived HTML page. Each canonical path is either public or carries a salted password verifier; the index resources have a hard-coded public policy.
  • Parse Basic authorization defensively using the fixed username page. Verify per-page passwords against a salted verifier, compare fixed-length digests, and never log credentials or authorization headers. Generated passwords carry high entropy; user-supplied weak passwords remain weak.
  • Return 401 with WWW-Authenticate: Basic and a short plain-text body showing curl --user "page:PASSWORD" URL, plus the interactive alternative curl --user page URL. Use a distinct realm derived from the page path so browsers do not confuse different pages’ cached credentials.
  • Normalize paths using new URL(request.url).pathname and cover both the source .html path and Pages’ extensionless canonical URL. Do not use substring or suffix allowlists.
  • Forward authorized requests with env.ASSETS.fetch(request), then clone protected responses with private, no-store caching headers.
  • Add local tests for public index/manifest, an explicitly public page, generated and supplied page passwords, wrong credentials, missing registry entries, GET/HEAD, encoded-path attempts, and unknown paths.
Phase 2 — Define the skill arguments and credential lifecycle medium
  • Document the same input contract in html-page, html-plan, and html-handoff: password=<value> sets the human password; password= requests a new random password; access=public disables password protection for that page. Reject a non-empty password combined with access=public.
  • For a new page, omitted password has the same secure default as password=: the script generates a cryptographically random URL-safe password. For an existing page, omitted access arguments preserve its current policy; password= explicitly rotates it to a newly generated password.
  • Use explicit deployment flags: --password-file <path>, --random-password, or --public. Never place a password directly in the process command line or environment. The skill passes a supplied value through a mode-0600 temporary file and deletes that file after registration.
  • Maintain ~/.config/html-page/auth.json under a mode-0700 directory with mode-0600 permissions. It records each canonical URL’s access mode and human password so generated passwords can be recovered and policies can be rebuilt.
  • Any agent with only URL + password can run curl --user "page:PASSWORD" URL. The unauthenticated 401 response advertises that contract so an unfamiliar agent can discover it. When process visibility matters, curl --user page URL prompts for the password instead.
  • Return a compact share capsule after deployment containing only the canonical URL, ACCESS=protected|public, and password when protected; the fixed username is discoverable from the 401 response. For a generated password, show it once and state that it is also stored in the restricted origin registry. A public capsule contains no password.
  • Treat the page password as the intentional capability being shared with the reader. Require a separately generated password per page by default, warn against reusing personal passwords or one password across pages, and reject accidental reuse of a generated password.
  • The publishing workflow must not store plaintext credentials in skill files, the HTML archive, pages.json, Git, its own process arguments, or deployment logs. A receiving agent may deliberately use the direct curl --user "page:PASSWORD" interoperability form.
# Works on any computer with only the link and password
curl --fail-with-body --user "page:PASSWORD" \
  https://miro-plans-xps13.pages.dev/pages/some-page

# Alternative: curl prompts for the password instead of putting it in argv
curl --fail-with-body --user page \
  https://miro-plans-xps13.pages.dev/pages/some-page
Phase 3 — Make secure publishing the default medium
  • Update deploy.sh to register or preserve the target page’s access policy, generate the worker/map before build-index.sh, refuse deployment when the registry is absent or invalid, and keep public index generation unchanged.
  • Generate a _routes.json that excludes only the intentionally public index resources from Function invocation and includes every other path. Set each Pages project’s quota behavior to fail closed; Cloudflare warns that fail-open mode can serve static assets when the Functions quota is exhausted.
  • Replace the current unconditional 200 check with policy-aware checks: index is 200; protected page is 401 without credentials and 200 with its password; public page is 200 without credentials.
  • Disable the ngrok fallback once protected publishing becomes the default. Restoring it with equivalent authentication is deferred until Miro actually needs that fallback.
  • Update the skills so random protection is the default, access=public is the explicit per-page opt-out, titles remain public, and agent handoff includes the standard curl command. Changing an existing protected page to public must be reported prominently with the final URL.
Phase 4 — Roll out one project and close legacy aliases careful
  1. Build an access-policy entry with a distinct generated password for every existing page before deploying the worker. Store the passwords in the restricted registry.
  2. Deploy to one project first. Test its canonical URL and deployment-specific alias: index and manifest public; protected bodies 401; deliberately public bodies 200; browser login and direct Basic-Auth curl both work.
  3. Inventory older deployment and branch aliases once. Replace any undeletable branch-head deployment with a gated version, then—after explicit approval—delete superseded unauthenticated deployments. Cloudflare keeps preview aliases reachable and does not allow deleting the latest deployment for a branch.
  4. Document that password rotation and public→protected revocation apply immediately to the current deployment only. When historical deployment URLs matter, remove or independently gate every older deployment carrying the superseded policy before declaring revocation complete.
  5. Allow rollback only to a deployment that contains the authentication gate and an acceptable access-policy map. A pre-auth or superseded-public deployment is not a valid rollback target.
  6. After the desktop project is proven, treat XPS13 and battlebox as a separate fleet-rollout decision. The public combined index may continue listing those sites during the pilot; do not claim their page bodies are protected until separately verified.

Migration references: persistent preview aliases, branch-head deletion restriction, delete a Pages deployment, and Pages rollback behavior.

Phase 5 — Deferred improvements, outside MVP scope
  • defer Capacity report, 80% alerts, and automated age/count cleanup. Manual inspection and Cloudflare’s supported deployment-delete command are enough initially.
  • defer Curl wrapper, site-wide bearer credentials, and automatic registry synchronization. Add only if direct URL + per-page password sharing proves insufficient.
  • defer Authenticated ngrok fallback and a custom logout endpoint. Keep the fallback disabled and document Basic Auth’s browser logout limitation.
  • defer Cloudflare Access migration. Revisit when individual identity, MFA, revocation, or audit logs become requirements.

Capacity model: Cloudflare’s current Free-plan limits are 20,000 files per Pages site, 25 MiB per individual asset, and 500 builds/deploys per month. It documents unlimited active preview deployments and no cumulative byte-storage quota for Pages. This machine’s current archive is 379 files / 67 MB; its largest file is about 9.5 MB. Deleting pages helps the per-site file count; it does not fix the monthly deploy quota or an individual file over 25 MiB. References: Pages limits, automated old-deployment deletion, Functions pricing and Free quota, and Cloudflare Access service tokens.

Risks

RiskMitigation / acceptance test
high Old deployment aliases preserve old access policiesInventory and probe them during migration. Replace gated branch heads where deletion is prohibited, and remove old policy versions when actual password/public-access revocation is required.
high Worker or registry entry omitted on a future deploydeploy.sh always installs the worker; worker fails closed; smoke test requires unauthenticated 401 before reporting success.
high Workers Free daily request quota is exhaustedSet Pages Runtime to fail closed, test that setting during rollout, and exclude public index resources with _routes.json. Protected pages become unavailable rather than public.
medium Password reused across pagesGenerate a unique password independently for every page by default and warn against supplying a reused personal/shared password. Per-page credentials keep an intentional share scoped to one body.
medium Registry/map drift changes access unexpectedlyGenerate the map transactionally, fail closed on missing entries, preserve existing policy when arguments are omitted, and smoke-test the target URL’s expected status after every deployment.
medium Weak user-selected passwordAccept it because the requested argument is authoritative, warn against reused/valuable passwords, and recommend the high-entropy generated default.
medium Link/password pair is forwarded furtherThis is the intended capability model. Unique per-page passwords limit the additional reader to one page; rotate that page’s password when access should be revoked.
medium Basic Auth browser caching and awkward logoutUse HTTPS only, no-store, document the browser limitation, and recommend a private browser window on shared devices. A custom logout endpoint is outside MVP.
medium Public index leaks titles and datesAccepted requirement. Keep page titles non-sensitive; do not put secrets or confidential client names in titles.
medium Cross-machine index breaksLeave pages.json and its CORS header public; add an end-to-end merged-index test after each machine rollout.

Open questions

  1. After secure deployments pass, may implementation delete older unauthenticated Cloudflare deployments?

    default Yes, after listing the exact deployment IDs/URLs and obtaining explicit destructive-action approval.

    alt Keep an old deployment only if it is independently gated and its policy is still acceptable; otherwise protection remains incomplete.

  2. How should the initial migration assign passwords to the already-published archive?

    default Generate a distinct strong password for every existing page and store them in the restricted registry. This preserves the one-password/one-page capability boundary.

  3. What is the cross-computer sharing contract?

    default Return URL + access mode + unique per-page password. Any receiving agent can use standard Basic Auth with fixed username page; the 401 response explains the curl command.