Password-gate any Astro site behind a signed, HMAC-based session cookie. A small library: a middleware factory you compose in your own src/middleware.ts, plus the auth helpers to back a login page. It ships no UI, so the page stays yours.
Runtime-neutral (WebCrypto only), so it runs on Node, Cloudflare Workers, Deno and Bun without a compat flag.
Declare the SITE_PASSWORD secret in your env schema:
// astro.config.mjs
import { defineConfig, envField } from 'astro/config'
export default defineConfig({
env: {
schema: {
SITE_PASSWORD: envField.string({
context: 'server',
access: 'secret',
optional: true,
}),
},
},
})Compose the gate in your middleware:
// src/middleware.ts
import { sequence } from 'astro:middleware'
import passwordProtect from '@plutotcool/astro-password-protect'
export const onRequest = sequence(passwordProtect())Add a login page at the gate's loginPath (default /login), backed by the helpers:
---
// src/pages/login.astro
import {
getPassword,
verifyPassword,
setSession,
hasValidSession,
} from '@plutotcool/astro-password-protect'
export const prerender = false
const secret = getPassword()
// … read the form, call verifyPassword / setSession / hasValidSession …
---The password is read at runtime from the SITE_PASSWORD server secret, and the gate is a no-op when it is empty — an unconfigured environment is never blocked.
It runs in middleware, on request, so it only covers routes rendered on demand:
- a fully static build (no adapter) has no server at runtime — nothing is gated;
- prerendered routes in a hybrid build are skipped, since they are built once, ahead of any request;
- pages you want behind the gate must be
prerender = false, and the site needs an adapter.
Set output accordingly, or opt individual routes out of prerendering. Static assets are served without the gate in every case.
| Option | Default | Description |
|---|---|---|
exclude |
[] |
Path prefixes always served without the gate (static assets are always excluded). |
bypass |
— | (context) => boolean | Promise<boolean> — let a request through when it returns true. |
loginPath |
'/login' |
Path the gate redirects to, and where your login page lives. |
bypass keeps the package agnostic. To let a CMS live-preview session through:
export const onRequest = sequence(
passwordProtect({
exclude: ['/api/preview'],
bypass: (context) => context.locals.preview,
}),
)Compose so that whatever sets the bypass flag runs first — an integration-injected pre middleware (e.g. a CMS preview) already runs before your src/middleware.ts.