| name | vercel-deployment-password-gate |
| description | A free DIY reimplementation of Vercel's $150/mo Advanced Deployment Protection add-on — all three features (Password Protection, private/prod deployments, Deployment Protection Exceptions) plus named automation bypass tokens — a middleware gate for ANY framework on Vercel (Next.js proxy, or SvelteKit/Nuxt/Astro/Remix/static via Routing Middleware). Gates previews by default, production opt-in; fully branded unlock page; zero prod cost. Use when asked to password-protect or basic-auth a preview/staging URL, avoid or cancel that add-on, password-protect on a Hobby plan, brand/white-label a password wall, add a login that "shows once and stays unlocked", set or ROTATE the password, add/remove bypass tokens for CI or automation (Lighthouse, uptime), make one preview domain public, protect previews on an app with NO existing middleware, gate a production or pre-launch site with a shared password (coming-soon, client demo, internal tool), or choose between a DIY gate and Vercel Authentication (free team SSO). |
| metadata | {"author":"stealth-factory","co-author":"wiiiimm","version":"1.11.11"} |
Vercel deployment password gate
A free, portable password wall for preview deployments by default — and for
production too, opt-in (see "Gating production too"). Humans see a brandable
unlock form once (a signed 1-year cookie keeps them in); automation passes with
named bypass tokens via header or query parameter. In the default preview-only
posture, production ships no middleware function at all (Mode B) or a
one-boolean short-circuit (Mode A), so the gate's production cost is zero. Only
a scrypt hash of the password is stored; unlock cookies are keyed
per-credential, so rotating the password or removing a bypass token revokes
exactly the cookies it issued.
What this reimplements
A free, DIY reimplementation of Vercel's Advanced Deployment Protection
add-on — $150/mo on Pro (30-day minimum before you can cancel), included on
Enterprise, and apparently not sold on Hobby at all — rebuilt in one
middleware file. Vercel bundles three features into the add-on — all three are
supported here:
| Advanced Deployment Protection | This skill |
|---|
| Password Protection | ✅ Unlock form + DEPLOY_GATE_PASSWORD_HASH (scrypt), mirroring the platform's semantics: enter once per deployment URL, and changing the password invalidates the cookies it issued. One difference: Vercel's change takes effect on existing deployments immediately; ours applies to new builds, so redeploy to revoke now. |
| Private Production Deployments (password on the production domain too) | ✅ Opt-in — previews by default, production via "Gating production too". Trade-off: the middleware then runs in prod, so the zero-prod-cost property is gone. |
| Deployment Protection Exceptions (unprotect specific preview domains) | ✅ DEPLOY_GATE_UNPROTECTED_HOSTS — comma-separated hosts that skip the gate and are public. Same axis as Vercel's (the domain); the matcher is a different thing (path-level: /api, static assets). See "Unprotect specific domains". |
Plus an equivalent of Protection Bypass for Automation: named, individually
revocable tokens, same header / query-param / set-cookie UX. This is parity,
not an improvement — Vercel's own bypass also supports multiple named,
individually revocable secrets ("You can create multiple bypass secrets per
project", docs updated 2026-04-30), and theirs additionally clears Firewall and
bot-protection challenges, which a DIY token cannot. The reason to use ours is
that it works with our gate; if you're on platform protection, use theirs.
Not reimplemented: Shareable Links (a per-recipient bypass token is the
closest analogue — no TTL), Trusted IPs/Passport (out of scope), and
Vercel Authentication, which can't be — that session lives on vercel.com.
Two things $150/mo can't buy you. The unlock page is yours — Vercel's
password screen is Vercel-branded with no documented theming hook (its whole
config surface across dashboard, API, and Terraform is deploymentType +
password), so matching a client's brand, logo, and design system is only
possible DIY. And on Hobby, where the docs indicate the add-on isn't sold,
this is very likely the only password option at all.
What the platform does better: it runs before your code (protects static
assets and every route, nothing to misconfigure), it can't fail open on a
missing env var, and it's Vercel's problem to maintain. This gate is a speed
bump, not auth — see the gotcha of the same name.
Release-method agnostic: everything keys off the Vercel environment
(VERCEL_TARGET_ENV), never branch names — it composes with any
promotion/branch/deploy model.
Step 0 — check whether you need this at all
| Need | Right tool |
|---|
| Only the Vercel team views previews | Vercel Authentication (Deployment Protection → Standard). Free on all plans, zero code, team members pass invisibly via their Vercel login. Prefer this when it fits. |
| External stakeholders | Vercel Authentication + Shareable Links (all plans; Hobby is capped at one link per account, Pro+ lifts the cap). Still zero code. |
| Anyone-with-a-password, for free | This skill. Vercel's Password Protection is Enterprise-only, or Pro + $150/mo for the Advanced Deployment Protection add-on (which you must keep ≥30 days before you can cancel) — both verified against Vercel's docs 2026-07-17; re-check pricing. On Hobby it appears unbuyable (the docs list it as "Enterprise, or a paid add-on for Pro", and say Hobby gets only Vercel Authentication) — inferred from the plan listings, not stated outright, so the DIY gate is very likely the only password option there. |
| The password page must carry your/your client's branding | This skill. Vercel's password screen is Vercel's — deploymentType + password is its whole documented config surface, with no theming hook. This gate renders your own HTML. |
| Non-Next framework, static export, or SPA on Vercel | Still this skill — use the framework-agnostic template via Vercel Routing Middleware (see "Pick your template"), which runs platform-level before the app or static assets. |
A DIY gate cannot detect "is this visitor logged into Vercel" — that session
lives on vercel.com and is only checkable by platform-level Vercel Authentication,
which runs before your code. Don't try to hybridize; pick per the table.
Already on Vercel Authentication, need limited third-party access?
The DIY gate can't help here (the platform wall blocks third parties before your
code runs). Use the platform's own bypass methods instead:
- Shareable Links — the purpose-built answer. Minted per deployment
URL/alias with optional TTL, individually revocable, no shared secret. Create
from the deployment's Share dialog in the dashboard, or via API
(
PATCH /aliases/{id}/protection-bypass, ttl). Available on Hobby too,
but capped at one link per account there; Pro+ lifts the cap (verified
2026-07-17).
- Protection Bypass for Automation — secrets in a crafted URL:
https://<preview-url>/?x-vercel-protection-bypass=<secret>&x-vercel-set-bypass-cookie=true
persists a bypass cookie. You can create multiple named secrets per
project, each revocable independently (docs updated 2026-04-30), and they
also clear Firewall/bot-protection challenges. Caveat: secrets in URLs end up
in logs, so prefer the header where the caller supports it.
Pick your template
Two templates, identical behavior, env vars, and helper scripts — pick by framework:
| Project | Template | Installs as |
|---|
| Next.js | templates/deploy-gate.ts — no deps beyond next/server + node:crypto | proxy.ts (Mode B) or lib/deploy-gate.ts (Mode A) |
| Anything else on Vercel (SvelteKit, Nuxt, Astro, Remix, static/SPA) | templates/deploy-gate.vercel.ts — uses Vercel Routing Middleware; one dep (@vercel/functions); config.runtime must stay "nodejs" (edge is the default and lacks node:crypto) | root middleware.ts, next to package.json |
The skill's mechanics (VERCEL_TARGET_ENV gating, env-var management via
vercel env, build-time strip) are Vercel-platform-wide, not framework-specific.
How the gate works (both modes)
Single self-contained file:
- Gates every remote non-production Vercel deployment —
preview and any
custom environment (e.g. a named "staging"). The signal is
VERCEL_TARGET_ENV (falling back to VERCEL_ENV when absent), NOT
VERCEL_ENV: VERCEL_ENV only ever reports production/preview/development
and collapses every custom environment into one of those buckets, so a custom
target can read VERCEL_ENV=production and slip through ungated.
VERCEL_TARGET_ENV carries the custom name. It fails open when that value
is production or development; when it's unset, it fails open only for a
genuine local dev server (NODE_ENV === "development") or when nothing is
configured — a Vercel deploy with System Env Vars disabled also reads unset but
runs NODE_ENV=production, so a configured one there still gates (see the
Mode B lifecycle note).
- No valid cookie → responds
401 with an inline HTML password form (no extra
routes/pages added to the app). Form POSTs to /__deploy-unlock.
- Human auth:
DEPLOY_GATE_PASSWORD_HASH stores s2:<salt>:<scryptHex> (scrypt, memory-hard)
— never the plaintext. Submitted passwords are run through scrypt and compared
constant-time. (Legacy fallback: a plaintext DEPLOY_GATE_PASSWORD also works.)
- Automation auth (mimics Vercel's Protection Bypass for Automation):
DEPLOY_GATE_BYPASS_TOKENS stores JSON {"<label>":"<token>", ...} —
plaintext by design (automation must read tokens back; they're generated
random, never human-reused). Send a token via the x-deploy-gate-bypass
header (passes through + sets the cookie) or query parameter (303
redirect to the cleaned URL — token stripped from the address bar — with the
cookie set, so one crafted link = click-once access for a service that can't
set headers). Bypass accepts tokens only — the human password never
works in the header or query param: verifying a password costs a memory-hard
scrypt run, so accepting it per-request would hand attackers a CPU-DoS
amplifier (and passwords don't belong in URLs). The password unlocks solely
via the form.
Why a cookie, not localStorage: the decision happens server-side in the
proxy before any JavaScript runs; the token must travel with the request.
localStorage physically cannot gate SSR. Same "enter once" UX.
Env-var names (and the legacy aliases)
All config vars use the DEPLOY_GATE_ prefix, because the gate protects
production too — the old PREVIEW_-prefixed names implied preview-only and were
misleading once production gating landed:
| Purpose | Current name | Legacy alias (still honoured) |
|---|
| Password hash | DEPLOY_GATE_PASSWORD_HASH | PREVIEW_PASSWORD_HASH |
| Plaintext password (legacy scheme) | DEPLOY_GATE_PASSWORD | PREVIEW_PASSWORD |
| Automation bypass tokens | DEPLOY_GATE_BYPASS_TOKENS | PREVIEW_GATE_BYPASS_TOKENS |
| Unprotected-host allowlist | DEPLOY_GATE_UNPROTECTED_HOSTS | (new — no alias) |
The gate reads the current name first and falls back to the legacy alias with
a one-time warning if only the old one is set — a rename can't fail open,
because absent config is intentionally fail-open (an existing install that still
has PREVIEW_PASSWORD_HASH keeps working; migrate at leisure). The current name
wins if both are set. To migrate, add the DEPLOY_GATE_* var and remove the
PREVIEW_* one; the alias support is a courtesy, not a permanent contract.
Mode A — app already has middleware.ts / proxy.ts
The middleware function already runs on every matched request, so the gate adds
one env-var boolean in production — no new invocations, no meaningful cost.
⚠️ If the host file is legacy middleware.ts, migrate it to proxy.ts
first (npx @next/codemod@canary middleware-to-proxy . — the tag Next's proxy
docs use for this codemod, or rename the file
- the exported function and fix test imports).
middleware.ts runs on the
Edge runtime even in Next 16, where node:crypto does not exist — the
gate 500s every request. Caught in a real install (piaf-web, Next 16.2.7,
2026-07-17): Error: Failed to load external module node:crypto.
-
Copy templates/deploy-gate.ts next to your
host proxy file and import it relatively. If the host proxy is at the
project root (proxy.ts), put the helper at lib/deploy-gate.ts; if the
host uses a src/ layout (src/proxy.ts), put it at src/lib/deploy-gate.ts
— a root ./lib/deploy-gate import from src/proxy.ts resolves to
src/lib/… and won't find a root lib/, so the build fails.
-
Wire it into the existing proxy() / middleware() function — check first,
attach the cookie to whatever response the pipeline produces last. Pass the
request protocol so the unlock cookie persists on a local http:// dev run:
import { previewGate, withUnlockCookie } from "./lib/deploy-gate";
export async function proxy(request: NextRequest) {
const gate = await previewGate(request);
if (gate.block) return gate.block;
// Strip the bypass token from the headers forwarded upstream, so your app /
// request logging never sees it (Mode B's proxy.ts does this for you). Pass
// THESE cleaned headers into your pipeline — don't reuse the raw `request`.
const headers = new Headers(request.headers);
headers.delete("x-deploy-gate-bypass");
const cleaned = new NextRequest(request.nextUrl, {
headers,
method: request.method,
body: request.body,
duplex: "half", // required whenever a (stream) body is forwarded, else POSTs throw
});
const response = await yourExistingLogic(cleaned);
const secure = request.nextUrl.protocol === "https:";
return gate.setCookie ? withUnlockCookie(response, gate.setCookie, secure) : response;
}
(If your host logic doesn't take a request argument — it reads globals or
NextResponse.next()s — set the cleaned headers on the continue-response
instead: NextResponse.next({ request: { headers } }).)
The setCookie path matters: a header-bypassed request must CONTINUE
through the host pipeline (i18n redirects, rewrites, analytics cookies) —
returning a bare pass-through from the gate would skip all of it (caught by
review on the piaf-web install).
-
Check the host matcher: it must not exclude /__deploy-unlock, and if it
excludes /api (piaf-web's did), decide deliberately — un-gated API routes
on previews are usually a hole. Include in the matcher and skip only
the host's page-routing logic for API paths.
Mode B — app has NO middleware (strict zero prod cost)
The gate file is the app's real, checked-in proxy.ts; a build step strips it
from production builds only:
-
Copy templates/deploy-gate.ts to proxy.ts
at the same level as your app/pages directory — the project root, or
src/proxy.ts if the app uses a src/ directory. ⚠️ Next only loads the
proxy at that level: a root proxy.ts in a src/-layout app is silently
ignored, and every preview then deploys ungated even with the password
set — the worst failure mode, because nothing errors. (It's checked in — it
IS the middleware; lintable, typecheckable.) If the app customises
pageExtensions (e.g. .page.ts), Next expects the proxy named to match —
proxy.page.ts — per Next's proxy docs; a plain proxy.ts is ignored (same
silent-ungate). Name the file accordingly and point the removal script's
CANDIDATES at it.
-
Copy templates/remove-proxy-on-prod.mjs
to scripts/remove-proxy-on-prod.mjs. It scans the common install paths
(proxy.ts, src/proxy.ts, middleware.ts, src/middleware.ts) for the
managed marker, so a src/ layout needs no edit; if the gate lives somewhere
else, add that path to its CANDIDATES array.
-
Chain it into the build (explicit chaining, not an npm prebuild hook —
pnpm skips pre/post scripts by default):
// package.json
"build": "node scripts/remove-proxy-on-prod.mjs && next build"
-
Verify after the first preview deploy — this catches the silent-ungate
class above in one command:
curl -sS -o /dev/null -w '%{http_code}\n' https://<your-preview-url>/
# expect 401 (gated). 200 means the proxy isn't running — check its location.
Non-Next frameworks: same steps, but the checked-in file is middleware.ts
at the project root only (next to package.json) from the framework-agnostic
template. Unlike Next's proxy.ts, Vercel Routing Middleware is only loaded
from the repo root — a src/middleware.ts copy is silently ignored and the
preview deploys ungated, so do NOT put it under src/ even in a src-layout app
(the src/* guidance above is Next-only). The removal script still scans the
src/ paths defensively, but don't rely on that. Chain the script before the
framework's own build command.
Lifecycle:
| Context | What happens |
|---|
Local next dev / a Vite dev server (NODE_ENV=development), vercel dev (VERCEL_ENV=development), non-Vercel hosts | Gate no-ops — a dev server is detected by NODE_ENV=development (or a development Vercel target), so it stays ungated even if you've pulled preview creds into .env.local. Test the gate locally by forcing a preview target: VERCEL_TARGET_ENV=preview DEPLOY_GATE_PASSWORD=test next dev. |
Vercel preview OR any custom environment (e.g. staging) build | File ships, gate active (keyed on VERCEL_TARGET_ENV). The build-strip only fires on true production (VERCEL_TARGET_ENV), so custom-env builds keep the proxy. |
| Vercel production build | Script deletes the gate file before next build → the deployment provisions no middleware function → zero invocations, zero cost, structurally. |
How local vs. preview is told apart (and why the System-Env toggle matters).
The gate passes on a production/development Vercel target, and on a local
dev server — detected by NODE_ENV === "development" (which next dev and
Vite set, and which a Vercel deployment never has at runtime). It gates every
remote target otherwise. The one ambiguous case is a Vercel preview with
"System Environment Variables" disabled (Vercel → Settings → Environment
Variables — normally on): with the toggle off, no VERCEL_* var reaches the
runtime, so the target reads undefined — but NODE_ENV is production
there (it isn't one of the toggle-gated VERCEL_* vars), so the gate can tell
it apart from local dev and still gates it. It fails safe either way. Two
costs of leaving the toggle off: (1) the build-strip needs VERCEL=1, so it
goes inert and production ships the (no-op) middleware — you lose the zero-cost
property; and (2) DEPLOY_GATE_UNPROTECTED_HOSTS stops working — host
exceptions are only honored when the target is a known Vercel env (Host is
spoofable otherwise), so with the toggle off a domain you marked public stays
gated. Recommend enabling it — required if you use host exceptions.
Safety guards in the removal script: it only deletes a file carrying the
@deploy-gate:managed marker (never hand-written middleware — if no marked file
is found it warns loudly and leaves everything, since a silent skip would
read as "stripped OK"), and it only acts inside a real Vercel TRUE-production
build (VERCEL=1 plus VERCEL_TARGET_ENV — falling back to VERCEL_ENV —
equal to production), so local builds never mutate the working tree and
custom-environment builds keep the gate. The file is git-tracked anyway;
git checkout -- proxy.ts restores it if anything ever goes sideways.
Gating production too (deliberate deviation)
By default this gate is preview-only — production always passes through, and
in Mode B the proxy is stripped from production builds entirely. Sometimes a user
genuinely wants a password wall on production: a pre-launch "coming soon"
site, a client demo on the real domain, or a private internal tool.
This skill can do that, but treat it as a fork, not a toggle — and start by
asking why, because the answer decides whether this is even the right tool.
First, the guardrail — is this the right tool? This gate is a shared-password
speed bump: one password, no per-user identity, no rate limiting (see the
"speed bump, not auth" gotcha). It is fine for a teaser page, a demo, or a
low-stakes internal tool. It is NOT access control for real user data,
accounts, payments, or anything you'd be embarrassed to see breached — for that,
steer the user to Vercel Authentication, Clerk, or a real IdP instead.
If they insist on this for a high-stakes surface, say plainly that it's a speed
bump, not a lock.
If it's a legitimate low-stakes case, make these three changes together:
-
Predicate — also gate production. In previewGate, drop the
production arm from the pass-through condition, but keep the stock
local-dev guard exactly — do NOT pass on a bare target === undefined.
A real Vercel deployment with System Environment Variables disabled also
reports undefined, so passing on it would leave your production site
public. Only a genuine local dev server (NODE_ENV === "development") or a
fully-unconfigured deployment should fall through:
const target = process.env.VERCEL_TARGET_ENV ?? process.env.VERCEL_ENV;
// gate production too — pass ONLY for the local dev server; `undefined`
// alone is NOT safe (a System-Env-disabled Vercel deploy reads undefined).
if (target === "development") return {}; // or { action: "pass" }
if (target === undefined && process.env.NODE_ENV === "development") return {};
// …then fall through to the stock config/credentials checks, which fail open
// only when nothing is configured.
-
Mode B ONLY — do NOT wire remove-proxy-on-prod.mjs. ⚠️ This is the
footgun. That script deletes the gate from production builds (the whole
zero-cost trick). If you gate production but leave the removal script in the
build chain, the proxy vanishes in prod and the site is wide open with no
warning. Remove the node scripts/remove-proxy-on-prod.mjs && from the
build command. (Mode A has no removal script, so nothing to undo there.)
-
Env vars — add the Production scope. The password hash and any bypass
tokens are Preview-scoped by default; add them to Production too, e.g.
… | vercel env add DEPLOY_GATE_PASSWORD_HASH production. Without this the
production gate has no configured password and fails open (absent config
is intentionally fail-open so a fresh clone isn't bricked).
Consequence to state to the user: the "zero production cost" property is
gone — the middleware now runs on every production request (one env check +,
when locked, the response). That's the price of gating production; it's small,
but it's no longer free.
Style the unlock page (required when installing)
The form in unlockFormHtml() is a deliberately neutral baseline. Styling it is
the payoff for doing this yourself — Vercel's paid Password Protection shows
Vercel's own screen with no documented theming hook, so a branded wall is
something the add-on cannot buy. It's also often the first thing a client or stakeholder sees.
When installing the gate into a real project, restyle it professionally to match
the project's existing look and feel — check for a design system, brand
tokens, fonts, logo, and how existing auth/error pages are styled, and mirror
them. If the project has no design language, keep it minimal and clean rather
than inventing one. Requirements:
- Responsive and mobile-first: fluid layout (no fixed widths), works from
~320px up,
min-height: 100dvh centering, comfortable touch targets
(≥44px), and ≥16px input font-size (prevents iOS auto-zoom on focus).
- Self-contained or same-origin only: inline all CSS; a logo may be inlined
as a data URI or referenced same-origin (the matcher lets static assets
through) — never load from external hosts.
- Support light AND dark (
color-scheme + prefers-color-scheme).
- Preserve the functional invariants:
method="post", the computed
action (UNLOCK_PATH + encoded from), name="password", the failed-state
error message, <meta name="robots" content="noindex">, autofocus,
autocomplete="current-password", and escapeHtml() on anything
interpolated. Style everything else freely.
Set or rotate the password (agent workflow)
Same flow for first-time setup and rotation — only the hash is ever stored:
-
Get the plaintext from the user (ask directly, or offer to generate one:
openssl rand -base64 12, show it to the user ONCE). Never write the
plaintext to any file, env file, commit, or log — shell history counts,
which is why the commands below never put it on a command line.
-
Hash it with the bundled script (matches the gate's scheme, random salt
each run). Run it with no argument so it prompts on stdin:
node <skill-dir>/templates/hash-password.mjs
# Deploy-gate password: ‹typed, not echoed to history›
# → s2:<salt>:<scryptHex>
The script also accepts hash-password.mjs '<plaintext>', but that lands the
password in shell history and in ps output — use it only for a throwaway
local test, never for a real password.
-
Store the hash, scoped to Preview only. Only the hash leaves the machine:
# first setup:
node <skill-dir>/templates/hash-password.mjs | vercel env add DEPLOY_GATE_PASSWORD_HASH preview
# rotation — in-place, no gap:
node <skill-dir>/templates/hash-password.mjs | vercel env update DEPLOY_GATE_PASSWORD_HASH preview
Use vercel env update to rotate, not rm then add: between an rm
and the next build the var is absent, and absent config fails open — a
deployment built in that window ships ungated. The prompt writes to stderr
and the hash to stdout, so the pipe carries only the hash. Pasting the s2:…
string into the Vercel dashboard is equivalent — the CLI is convenience, not
a requirement. (The hash is fine stored sensitive, Vercel's default —
rotation mints a fresh hash and never needs to read the old one back.)
Project uses custom environments (e.g. staging)? Repeat for each one
(… | vercel env add DEPLOY_GATE_PASSWORD_HASH staging) — custom environments
have their own env-var scope on Vercel and do NOT inherit Preview vars, yet
the gate DOES activate there; leave the var unset and that environment has
absent config → fails open, silently ungated.
-
Tell the user the rotation semantics:
- New deployments use the new hash immediately; all previously issued
password cookies stop working on them (cookie is keyed on the hash).
- Already-deployed previews keep honoring the old password until each is
redeployed — Vercel env changes apply to new builds only. Redeploy the
stable staging alias if immediate revocation matters.
Manage automation bypass tokens (agent workflow)
Named, individually revocable machine credentials, stored as JSON in
DEPLOY_GATE_BYPASS_TOKENS (Preview scope — plus each custom environment,
which has its own env-var scope; same caveat as the password hash). Use
templates/bypass-tokens.mjs — it's pure
(JSON in → JSON out on stdout, human summary + generated token on stderr), the
agent glues it to vercel env:
⚠️ Store this var --no-sensitive — the workflow depends on reading it
back. vercel env add defaults to sensitive for Preview (and the
"make it sensitive?" prompt is skipped when the value arrives via a pipe, as
it does here — so the default applies silently), and sensitive values can't
be pulled or listed afterward. Add/remove edits the existing token map, so
a map stored sensitive is unrecoverable: the next edit rebuilds from {} and
revokes every other token. Always pass --no-sensitive (the tokens are
plaintext by design anyway). If a team policy enforces sensitive, keep the
token map's source of truth outside Vercel (a secrets manager), or accept
rotate-all semantics. A map already stored sensitive can't be salvaged —
regenerate all tokens from {} and re-point the automation.
-
Read the current value (skip on first setup):
vercel env pull --environment=preview /tmp/dg.env
grep '^DEPLOY_GATE_BYPASS_TOKENS=' /tmp/dg.env # KEY="json" — strip the quotes to get raw JSON
rm /tmp/dg.env # don't leave it around
-
Edit, capturing the new map in ONE run (label examples: ci,
lighthouse, uptime). Run the helper exactly once and keep its stdout —
add mints a fresh random token per invocation, so running it twice stores
a different token than the one you showed the user:
NEW="$(node <skill-dir>/templates/bypass-tokens.mjs add ci '<current-json-or-empty>')"
# or: NEW="$(node <skill-dir>/templates/bypass-tokens.mjs remove lighthouse '<current-json>')"
node <skill-dir>/templates/bypass-tokens.mjs list '<current-json>' # read-only, no write-back
add writes the new map to stdout and the generated token + summary to
stderr; show that token to the user once. It refuses duplicate labels —
rotate by remove + add.
-
Write back the $NEW map you captured in step 2 — pipe that exact JSON,
don't re-run the helper (a second add would mint a different token):
# first setup:
printf '%s' "$NEW" | vercel env add DEPLOY_GATE_BYPASS_TOKENS preview --no-sensitive
# thereafter (in-place, no fail-open gap):
printf '%s' "$NEW" | vercel env update DEPLOY_GATE_BYPASS_TOKENS preview --no-sensitive
--no-sensitive is required so step 1 can read the map back next time (see
the warning above). update avoids the rm→add window where the var is
absent and the gate fails open.
-
Usage by automation (tell the user):
- Header (CI, Playwright, curl):
x-deploy-gate-bypass: <token>
- Query param (services that can't set headers; also human click-once
links):
https://<preview-url>/path?x-deploy-gate-bypass=<token> — the
gate 303s to the cleaned URL and sets the cookie.
- Mimic Vercel's
VERCEL_AUTOMATION_BYPASS_SECRET convention: designate one
token (e.g. ci) and store it as a CI secret named
DEPLOY_GATE_BYPASS_SECRET for workflows to read.
Unprotect specific domains (Deployment Protection Exceptions)
The equivalent of Vercel's Deployment Protection Exceptions: list hosts that
skip the gate entirely. Use it when one preview domain must be public — a
stable demo URL for a client, a webhook receiver, a domain an external service
crawls — while every other preview stays locked.
# comma-separated; exact hosts, not patterns. --no-sensitive so you can read
# the list back to append to it later (it isn't a secret — it's public hosts).
printf 'demo.acme.com, hooks-preview.acme.com' \
| vercel env add DEPLOY_GATE_UNPROTECTED_HOSTS preview --no-sensitive
# to append later: pull + edit the list, then `vercel env update … --no-sensitive`
Semantics, matching Vercel's feature (and its dashboard's deliberate friction —
it makes you type "unprotect my domain" for a reason):
- A listed host is fully public. Not "password optional" — no wall at all.
Anything reachable on that host is world-readable. Confirm the host with the
user before adding it, and say plainly what goes public.
- Exact host match, case-insensitive, port-stripped. Not a suffix or
wildcard match — listing
acme.com does not unprotect demo.acme.com. This
is deliberate: a suffix match would unprotect every subdomain from one typo.
List each host explicitly.
- Checked before the config check, so an exception still holds if
DEPLOY_GATE_PASSWORD_HASH is malformed — otherwise the fail-closed 503 would
take down a domain the operator explicitly marked public.
- Bare hostnames only —
demo.acme.com, not https://demo.acme.com/ and
not an IP literal. A malformed entry is ignored with a warning and that
domain stays gated (check build/function logs if an exception seems inert).
- Removing a host re-protects only NEW builds. Vercel's dashboard version
re-protects existing deployments immediately; this one is an env var, so every
already-deployed preview on that host stays public until redeployed — same
lag as password rotation. Redeploy if it matters.
- Unset (the default) = nothing is excepted, so existing installs are
unaffected.
- Vercel-only, and needs "System Environment Variables" ON. The check trusts
the
Host header, which is safe here because Vercel's edge routes on that same
value — you can't forge it into reaching a deployment you weren't routed to.
That's a property of Vercel's routing, not of the attacker. So the gate honors
exceptions only when it can confirm it's on Vercel (a known
VERCEL_TARGET_ENV/VERCEL_ENV). With the System-Env-Vars toggle off those
vars are absent, so exceptions are silently not honored and the host stays
gated — enable the toggle if you use this var. Self-hosted or behind a proxy
that routes on the absolute-form target or TLS SNI while forwarding the client's
Host, Host is spoofable — don't use this var off Vercel.
- Scope it like the other vars: Preview, plus each custom environment.
- Vercel's version is preview-domains-only. This one keys off the request host,
so if you've opted into gating production it will except a production host
too — which is exactly the "public marketing page, gated app" split, but make
sure that's what you meant.
Gotchas
- Static assets are public — matters for SPAs / static sites. The
matcher
excludes real static-asset requests (.js, .css, images, fonts, source
maps) so they never run the gate. For an SSR app that's fine — those are
framework code. But a static export or SPA often bakes its content or data
into the hashed JS bundle, and with the default matcher anyone who learns an
asset URL can fetch that material without the password — the gate only
protects the HTML shell. If your bundles carry anything sensitive, gate
everything: set the matcher to "/((?!favicon\\.ico$).*)" (only the tab icon
stays public) and inline the unlock page's logo as a data URI (a same-origin
logo would otherwise be gated). It costs one cheap cookie-compare per asset
request (scrypt runs only on the unlock POST), and in Mode B production still
ships no middleware at all. This is the "speed bump, not auth" line in
practice — decide per app.
- Custom environments don't inherit Preview env vars. The gate activates on
custom environments (
VERCEL_TARGET_ENV=staging), but vercel env add … preview doesn't reach them — each custom environment is its own scope. Add
DEPLOY_GATE_PASSWORD_HASH (and any bypass tokens) per custom environment, or
use "Import variables" when creating it; otherwise that environment sees
absent config and fails open, silently ungated.
- Cookies are per-origin. The stable branch alias (
*-git-main-*.vercel.app)
unlocks once, permanently — but every PR's unique preview URL prompts once per
browser. Expected behavior, warn stakeholders.
- Upgrading from an older install re-prompts once. The cookie name changed
(
preview_gate → deploy_gate) and the unlock HMAC context changed with it,
so anyone currently unlocked will see the form one more time after you deploy
this version. Harmless, one-time. (The bypass header also changed:
x-preview-gate-bypass → x-deploy-gate-bypass — update any automation that
sends it. The old header is not accepted.)
- Query-param tokens can land in logs (server/proxy access logs capture the
first request even though the gate strips the URL afterward) — same caveat
Vercel documents for its own bypass query param. Prefer the header where the
caller supports it; treat leaked tokens as rotate-on-suspicion.
- Next ≤15 / edge runtime: Next 16's
proxy.ts is Node-runtime-only, which
is what the template assumes (node:crypto). On Next ≤15 rename the file to
, the export to (and the removal-script target to
match); if it runs on the edge runtime, replace with Web Crypto
(/ + a manual XOR-fold compare) — edge has no
.
Provenance & changelog: how every non-obvious claim was verified, and the full version history, live in reference/changelog.md. In brief — Vercel pricing/plan/behavior and Next.js proxy placement are verified against the official docs (2026-07-17); the gate's decision logic, env-var fallback, host-matching, and the removal script are smoke-tested with runnable harnesses; and the security-sensitive changes (scrypt hashing, tokens-only bypass, the two fail-open fixes) each trace to a specific review finding recorded there.