From 98993a18c616ebd63218e17897cb1468d861c45f Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Fri, 14 Aug 2026 16:26:36 +0200 Subject: [PATCH 1/3] docs(middleware): site-wide HTTP Basic Auth example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the built-in gate first (VERYFRONT_BASIC_USER/PASS operator env vars or security.auth.basic in veryfront.config — constant-time comparison, orchestrator probe paths stay reachable) and then a custom root-middleware variant for cases the built-in does not cover, reading credentials from c.env with a process.env fallback and failing closed when none are configured. Both variants verified against a running dev server: 401 for missing, wrong, and malformed credentials; 200 with the demo pair; /healthz and /readyz exempt from the built-in gate. --- docs/guides/middleware.md | 104 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/docs/guides/middleware.md b/docs/guides/middleware.md index 7d4330ac45..1bfbcfa604 100644 --- a/docs/guides/middleware.md +++ b/docs/guides/middleware.md @@ -218,6 +218,110 @@ A signed channel dispatch bypasses root middleware on the same terms. This is th Production loading is fail-closed. If a declared middleware file cannot be read, compiled, or validated as a middleware export, a dedicated server does not start and a shared server returns an error only for the affected project request. Failed shared loads are not cached, so a corrected deployment can recover without restarting unrelated projects. Development loading remains nonfatal and reports the loading error in the server log. +## Example: site-wide HTTP Basic Auth + +A common use of root middleware is password-gating an entire site — a staging +environment, a preview, an internal tool. + +### Prefer the built-in gate + +Before writing middleware, know that the runtime ships this as configuration. +Set the operator environment variables in the deployment environment: + +```bash +VERYFRONT_BASIC_USER=demo-user +VERYFRONT_BASIC_PASS=demo-pass +``` + +or configure it per project: + +```ts +// veryfront.config.ts +export default { + security: { + auth: { + basic: { + username: "demo-user", + password: process.env.BASIC_AUTH_PASS!, + realm: "Staging", + }, + }, + }, +}; +``` + +The built-in gate compares credentials in constant time and keeps the +platform's health probes and signed control-plane traffic working, so prefer +it whenever "one username and password for the whole site" is all you need. +(`security.auth.bearer` is the token-header equivalent; configure one or the +other, not both.) + +### Custom Basic Auth middleware + +Write it yourself when you need logic the built-in gate does not have — say, +exempting a public path or accepting several credential pairs: + +```ts +// middleware.ts +import type { MiddlewareHandler } from "veryfront/middleware"; + +function unauthorized(): Response { + return new Response("Authentication required", { + status: 401, + headers: { + "WWW-Authenticate": 'Basic realm="Demo", charset="UTF-8"', + }, + }); +} + +const basicAuth: MiddlewareHandler = async (c, next) => { + // Credentials come from the project environment: the shared hosted runtime + // delivers it through `c.env`, while local development and dedicated + // servers expose it as `process.env`. Fail closed: if none are configured, + // nobody gets in — never ship fallback credentials in code. + const user = String(c.env.BASIC_AUTH_USER ?? process.env.BASIC_AUTH_USER ?? ""); + const pass = String(c.env.BASIC_AUTH_PASS ?? process.env.BASIC_AUTH_PASS ?? ""); + if (!user || !pass) return unauthorized(); + + const header = c.request.headers.get("authorization") ?? ""; + if (!header.startsWith("Basic ")) return unauthorized(); + + let decoded: string; + try { + decoded = atob(header.slice(6)); + } catch { + return unauthorized(); // malformed base64 + } + + const sep = decoded.indexOf(":"); + if (sep === -1) return unauthorized(); + + if (decoded.slice(0, sep) === user && decoded.slice(sep + 1) === pass) { + return next(); + } + return unauthorized(); +}; + +export default basicAuth; +``` + +Set `BASIC_AUTH_USER` and `BASIC_AUTH_PASS` in the project environment +(`.env` locally, the environment settings of your deployment in production) +and try it: + +```bash +# Expect 401 with a WWW-Authenticate challenge +curl -i http://localhost:3000/ + +# Expect the page with the demo credentials +curl -i -u demo-user:demo-pass http://localhost:3000/ +``` + +Two things the hand-rolled version gives up relative to the built-in gate: +the `===` comparisons are not constant-time, and the exemptions described +above still apply — signed platform dispatches bypass root middleware, so +this gates your visitors, not the platform's own traffic. + ## Verify it worked Hit a route with and without the headers the middleware expects: From c86f98e22933414752c0ca98829416c3b0536980 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Fri, 14 Aug 2026 16:47:06 +0200 Subject: [PATCH 2/3] docs(middleware): use ASCII punctuation in the basic-auth section The public-docs validator rejects em dashes; docs:public:check now passes across all 123 files. --- docs/guides/middleware.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/guides/middleware.md b/docs/guides/middleware.md index 1bfbcfa604..de1888245c 100644 --- a/docs/guides/middleware.md +++ b/docs/guides/middleware.md @@ -220,7 +220,7 @@ Production loading is fail-closed. If a declared middleware file cannot be read, ## Example: site-wide HTTP Basic Auth -A common use of root middleware is password-gating an entire site — a staging +A common use of root middleware is password-gating an entire site: a staging environment, a preview, an internal tool. ### Prefer the built-in gate @@ -258,7 +258,7 @@ other, not both.) ### Custom Basic Auth middleware -Write it yourself when you need logic the built-in gate does not have — say, +Write it yourself when you need logic the built-in gate does not have, say, exempting a public path or accepting several credential pairs: ```ts @@ -278,7 +278,7 @@ const basicAuth: MiddlewareHandler = async (c, next) => { // Credentials come from the project environment: the shared hosted runtime // delivers it through `c.env`, while local development and dedicated // servers expose it as `process.env`. Fail closed: if none are configured, - // nobody gets in — never ship fallback credentials in code. + // nobody gets in. Never ship fallback credentials in code. const user = String(c.env.BASIC_AUTH_USER ?? process.env.BASIC_AUTH_USER ?? ""); const pass = String(c.env.BASIC_AUTH_PASS ?? process.env.BASIC_AUTH_PASS ?? ""); if (!user || !pass) return unauthorized(); @@ -319,7 +319,7 @@ curl -i -u demo-user:demo-pass http://localhost:3000/ Two things the hand-rolled version gives up relative to the built-in gate: the `===` comparisons are not constant-time, and the exemptions described -above still apply — signed platform dispatches bypass root middleware, so +above still apply: signed platform dispatches bypass root middleware, so this gates your visitors, not the platform's own traffic. ## Verify it worked From 465da22988ce91ccb7edfcac04f45a93df6dcba5 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Fri, 14 Aug 2026 16:58:56 +0200 Subject: [PATCH 3/3] docs(middleware): address basic-auth review findings - Read the config secret through getEnv from veryfront: the hosted declarative config evaluator rejects process.env as a forbidden capability, and an unset password fails config validation (safe failure). - Accept a case-insensitive authentication scheme per RFC 7235. - Decode the base64 credential bytes as UTF-8 (fatal) before comparing, so non-ASCII credentials work as the charset="UTF-8" challenge promises. - Distinguish the root middleware.ts file, which the shared hosted runtime compiles and runs, from the middleware.custom config option, which hosted runtimes reject. Revised example re-verified against a running dev server: 401 for missing, wrong, and malformed credentials; 200 for a UTF-8 username through both 'Basic' and 'basic' schemes. --- docs/guides/middleware.md | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/docs/guides/middleware.md b/docs/guides/middleware.md index de1888245c..71aa9914e6 100644 --- a/docs/guides/middleware.md +++ b/docs/guides/middleware.md @@ -237,12 +237,15 @@ or configure it per project: ```ts // veryfront.config.ts +import { getEnv } from "veryfront"; + export default { security: { auth: { basic: { username: "demo-user", - password: process.env.BASIC_AUTH_PASS!, + // An unset password fails config validation, which is the safe failure. + password: getEnv("BASIC_AUTH_PASS") ?? "", realm: "Staging", }, }, @@ -250,6 +253,11 @@ export default { }; ``` +Read config secrets through `getEnv` from `veryfront`, not `process.env`: the +hosted declarative config evaluator rejects `process.env` access as a +forbidden capability, while `getEnv` works in local, dedicated, and shared +runtimes. + The built-in gate compares credentials in constant time and keeps the platform's health probes and signed control-plane traffic working, so prefer it whenever "one username and password for the whole site" is all you need. @@ -259,7 +267,12 @@ other, not both.) ### Custom Basic Auth middleware Write it yourself when you need logic the built-in gate does not have, say, -exempting a public path or accepting several credential pairs: +exempting a public path or accepting several credential pairs. This is the +root `middleware.ts` file described above, which every runtime, including the +shared hosted runtime, compiles and runs. Do not confuse it with the +`middleware.custom` config option: config-declared middleware functions are +rejected by hosted runtimes and work only when you run or self-host the +project yourself. ```ts // middleware.ts @@ -284,13 +297,19 @@ const basicAuth: MiddlewareHandler = async (c, next) => { if (!user || !pass) return unauthorized(); const header = c.request.headers.get("authorization") ?? ""; - if (!header.startsWith("Basic ")) return unauthorized(); + // The scheme name is case-insensitive: "basic" is as valid as "Basic". + if (header.slice(0, 6).toLowerCase() !== "basic ") return unauthorized(); let decoded: string; try { - decoded = atob(header.slice(6)); + // atob() yields one byte per character; decode those bytes as UTF-8 so + // non-ASCII credentials compare correctly. + const binary = atob(header.slice(6)); + decoded = new TextDecoder("utf-8", { fatal: true }).decode( + Uint8Array.from(binary, (character) => character.charCodeAt(0)), + ); } catch { - return unauthorized(); // malformed base64 + return unauthorized(); // malformed base64 or invalid UTF-8 } const sep = decoded.indexOf(":");