Put one shared password in front of any web app. No database, no user accounts, no auth provider.
You build a client preview, a staging site, or a marketing page, and you want one password in front of it. Not real accounts. Not SSO. Just "type the password to come in."
On Vercel the built-in answer is Advanced Deployment Protection, which runs about $150/month and comes with a 30-day minimum commitment. That is a lot of money and lock-in for a single password.
sitepass is that single password. It is a small framework-agnostic core plus thin adapters, with zero runtime dependencies, and it runs on edge runtimes, Cloudflare Workers, Deno, Bun, and Node 20+.
A password gate is only real if the check runs on a server, before the protected bytes leave the building. So sitepass gives you two ways to install it:
- Host edge adapters (Cloudflare Pages, Netlify Edge, and a standalone reverse proxy). These run on the HTTP request before a file is served, so they gate any output, including pure SPAs and fully static sites.
- Framework server adapters (Next, Astro, SvelteKit, Express, Hono, Bun) for nicer ergonomics where the framework already has a server hook.
When a visitor has no valid session, they get a self-contained login page. They submit the password, the server checks it, and on success it sets a signed, HttpOnly cookie and redirects them back to where they were headed. The cookie is a stateless HMAC token, so there is no session store to run.
npm install sitepass
# or: pnpm add sitepass / bun add sitepassThen generate a secret and get the snippet for your target:
npx sitepass initinit writes the right env file (.env, or .dev.vars for Cloudflare), generates SITEPASS_SECRET, and prints the wiring for the target you pick. Re-running it never overwrites an existing secret.
You always set two environment variables:
SITEPASS_PASSWORDis the shared password visitors type.SITEPASS_SECRETis a random 32+ byte string that signs session cookies. Keep it private. Secrets shorter than 16 characters are treated as unconfigured and the gate fails closed.
Create functions/_middleware.ts:
import { gate } from 'sitepass/cloudflare'
export const onRequest = gate()Set SITEPASS_PASSWORD and SITEPASS_SECRET in your Pages project (and in .dev.vars for wrangler pages dev). That is the whole setup. Every route is now gated before any asset is served.
Create netlify/edge-functions/gate.ts:
import { gate, config } from 'sitepass/netlify'
export default gate()
export { config }The config re-export routes the edge function to every path — without it the function never runs and nothing is gated. Set the two environment variables in your Netlify site settings.
Create middleware.ts (Next 15 and earlier) or proxy.ts (Next 16+):
import { gate } from 'sitepass/next'
export const middleware = gate() // Next 16+: export const proxy = gate()
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}Not from inside the app. A password check that runs in the browser is not security: the page and its JavaScript are already on the visitor's machine, and a client-side guard is bypassed with devtools. Use the Cloudflare Pages or Netlify Edge adapter, which gates the request before any file is served. Same answer for a fully static Astro site.
sitepass deliberately ships no React, Angular, or Vue component that hides content in the browser, because that would be obfuscation dressed up as protection.
| Target | Import | Where it runs | Gates static output |
|---|---|---|---|
| Cloudflare Pages | sitepass/cloudflare |
functions/_middleware.ts |
Yes |
| Netlify Edge | sitepass/netlify |
netlify/edge-functions/gate.ts |
Yes |
| Reverse proxy | sitepass proxy |
standalone Node/Bun server | Yes |
| Next.js >= 13 | sitepass/next |
middleware.ts / proxy.ts |
App routes (see matcher note) |
| Astro >= 4 | sitepass/astro |
src/middleware.ts |
On-demand routes only |
| SvelteKit >= 2 | sitepass/sveltekit |
src/hooks.server.ts |
SSR routes only |
| Express >= 4 | sitepass/express |
app.use(gate()) |
Yes |
| Hono >= 4 | sitepass/hono |
app.use(gate()) |
Yes |
| Bun | sitepass/bun |
Bun.serve({ fetch: gate(handler) }) |
Yes |
Every adapter is a factory: call gate(options) and wire the result into the host's request hook. The host edge adapters and the reverse proxy are the ones that gate a pure SPA or a fully static site, because they run before a file is served.
For a self-hosted static site with no edge layer, run the gate as a proxy in front of your origin:
sitepass proxy --origin http://localhost:8080 --port 8788It reads SITEPASS_PASSWORD and SITEPASS_SECRET from the environment (and .env if present), gates each request, and streams the origin response back on success. The origin receives X-Forwarded-For/-Proto/-Host derived from the connecting socket — so -Proto is always http, since the proxy itself only speaks plain HTTP. Behind a TLS terminator (Caddy, nginx), pass --trust-proxy (trustProxy: true) so the terminator's X-Forwarded-* reach the origin instead; enable it only when clients cannot reach the proxy directly, because it trusts whatever the connecting peer sends. The gate's own session cookie is stripped before forwarding. Gate options are available as flags — see the CLI reference below. Forwarded request bodies are buffered with a 10 MiB cap (maxBodyBytes), so uploads larger than that will not pass; WebSocket upgrades are not supported.
The proxy is also importable for custom setups, and accepts every gate option from the Configuration section — except maxBodyBytes, which on the proxy caps the forwarded request body (default 10 MiB; the login body stays capped at 64 KiB):
import { startProxy } from 'sitepass/proxy'
startProxy({
origin: 'http://localhost:8080',
port: 8788,
password: process.env.SITEPASS_PASSWORD ?? '',
secret: process.env.SITEPASS_SECRET ?? '',
publicPaths: ['/health'],
})Pass options to any adapter's gate():
gate({
cookieName: 'gate', // session cookie name
sessionSeconds: 60 * 60 * 24 * 7, // session length (default 7 days)
loginPath: '/__gate', // path that accepts the login POST
publicPaths: ['/health', '/api/webhooks'], // bypass the gate (exact or path-prefix)
failOpen: false, // if password/secret are unset: false = 503, true = let traffic through
bypassToken: undefined, // see "Letting robots through" below
cookieSecure: true, // set false only for plain-HTTP (LAN) deployments
maxBodyBytes: 64 * 1024, // login POST body cap before responding 413
onAuthFailure: (request) => {}, // observer for failed login attempts
renderLoginPage: undefined, // replace the built-in login page (see below)
brand: {
title: 'Protected',
subtitle: 'Enter the password to continue.',
accent: '#4f46e5',
},
})password and secret are not options on the adapters. Every adapter reads them from the environment so they never end up in your source. (The core API, createGate, takes them directly — see below.) publicPaths matches whole path segments, so /api/webhooks covers /api/webhooks/stripe but not /api/webhooksxyz; / is an exact match for the root path only.
The Bun adapter is the one exception to the factory shape: the handler comes first — gate(myHandler, options).
The Next.js matcher is a tradeoff. Excluding _next/static keeps middleware invocations (and cost) down, but the raw JS chunks under that path are then reachable without the password. The actual page content lives in the gated HTML and RSC payload, so the protected text and data stay behind the gate; the build artifacts do not.
Set SITEPASS_BYPASS_TOKEN in the environment (or pass bypassToken to gate()), then send it as a header — no login form scripting needed:
curl -H "x-sitepass-bypass: $SITEPASS_BYPASS_TOKEN" https://staging.example.com/healthThe comparison is constant-time, like the password check. Treat the token like a password: long, random, rotated when people leave.
GET <loginPath>/logout (default /__gate/logout) clears the session cookie and redirects to /, which shows the login page again. Handy when testing the gate or leaving a shared machine.
renderLoginPage replaces the built-in page entirely — for localization, a logo, or matching your product:
import { escapeHtml } from 'sitepass'
gate({
renderLoginPage: ({ loginPath, next, error, brand }) => `<!doctype html>
<h1>${brand.title}</h1>
${error ? '<p>Mot de passe incorrect.</p>' : ''}
<form method="post" action="${loginPath}">
<input type="hidden" name="next" value="${escapeHtml(next)}" />
<input name="password" type="password" autofocus required />
<button>Entrer</button>
</form>`,
})The form must POST to loginPath with a password input and the hidden next input, and you must HTML-escape next when interpolating it.
onAuthFailure fires on every wrong-password attempt — wire it to your logging or alerting (a POST <loginPath> returning 401 in your access logs is the same signal, ready for fail2ban). It receives only a redacted { method, path } view, never the submitted password or the session cookie, so logging it can't persist credentials:
gate({ onAuthFailure: ({ path }) => console.warn(`sitepass: failed login at ${path}`) })sitepass init [--target <name>] [--password <pw>] [--env-file <path>]
sitepass proxy --origin <url> [--port <n>] [--env-file <path>]
[--public-paths <a,b>] [--login-path <path>]
[--cookie-name <name>] [--session-seconds <n>]
[--bypass-token <token>] [--insecure-cookie] [--trust-proxy]
sitepass --help | --version (-h / -v, accepted on any command)
init is interactive in a terminal; in scripts and CI, pass --target (one of cloudflare, netlify, next, astro, sveltekit, express, hono, bun). --env-file redirects where init writes and where proxy reads — note that Node 20.7+ pre-scans --env-file itself, so a missing file aborts with Node's own not found error before sitepass's message. --insecure-cookie drops the cookie's Secure attribute for plain-HTTP LAN origins — without it, browsers reject the cookie and login loops. --trust-proxy passes the front hop's X-Forwarded-* through to the origin (see the Reverse proxy section). --help and --version win over any command, so sitepass init --help prints usage instead of starting an interactive init. Unknown flags are an error, never silently ignored.
The root export is the framework-agnostic core. If your host is not covered by a shipped adapter, the whole contract is one function:
import { createGate, readCookie } from 'sitepass'
const gate = createGate({
password: process.env.SITEPASS_PASSWORD ?? '',
secret: process.env.SITEPASS_SECRET ?? '',
})
// For each request, normalize → handle → translate:
const result = await gate.handle({
method: request.method,
path: url.pathname,
search: url.search,
cookie: readCookie(request.headers.get('cookie'), gate.cookieName),
bypassToken: request.headers.get('x-sitepass-bypass') ?? undefined,
body: isLoginPost ? await request.text() : undefined,
})
// result is { type: 'pass' } | { type: 'redirect', location, setCookie }
// | { type: 'html', status, body, headers }See CONTRIBUTING.md for the full adapter contract.
- The session token is
base64url(expiry) "." base64url(HMAC-SHA256(expiry "." passwordTag, secret)). Verification recomputes the HMAC and compares it in constant time, then checks the expiry. There is no server-side session store. - A digest of the password is part of the signed message, so rotating either the password or the secret invalidates every outstanding session.
- The password check HMACs both the submitted and configured passwords and compares the two fixed-length digests in constant time, so it does not leak length or content through timing.
- The cookie is
HttpOnly,Secure(unlesscookieSecure: false),SameSite=Lax,Path=/. - A missing password, a missing secret, or a secret shorter than 16 characters fails closed (HTTP 503) by default.
failOpen: trueopts out. - The
nextredirect target is validated to a same-site absolute path, so the login form cannot bounce a visitor to another origin. - The core uses only Web Crypto and other platform globals. No Node-only APIs, no dependencies.
- A shared-password gate is brute-forceable, and there is no built-in rate limiting because there is no datastore. Use a long passphrase, and use
onAuthFailureor your access logs (POST /__gate→ 401) to alert or ban. - It is one password for everyone. There are no per-user accounts, roles, or audit logs. If you need those, you want a real auth provider.
- The Astro adapter only enforces on routes rendered on demand. A fully static Astro build runs middleware at build time, not per request, so use the Cloudflare or Netlify adapter for static Astro (or set
export const prerender = falsewith an adapter). - The SvelteKit adapter has the same boundary:
handleonly runs for server-rendered requests, so prerendered pages and the client assets under/_appare served without the gate. For a fully prerendered SvelteKit site, use the Cloudflare or Netlify adapter. - The cookie's
Secureattribute requires HTTPS — except onlocalhost, where Chrome and Firefox accept it over plain HTTP (Safari does not; usewrangler pages dev --local-protocol=https, a tunnel, or test in another browser). For a plain-HTTP LAN deployment behind the reverse proxy, use--insecure-cookiedeliberately. - The reverse proxy buffers forwarded request bodies (10 MiB cap by default) and does not tunnel WebSocket upgrades.
MIT