Vercel deployment password gate
Skill stealth-engine/skills/skills/vercel-deployment-password-gate
Agent skills we use daily in the studio — reusable, on-demand context for Claude Code, Cursor & other skills.sh-compatible AI agents. Install: npx skills add stealth-engine/skills
npx -y skills add stealth-engine/skills --skill vercel-deployment-password-gateAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 2 stars2 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
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).
SKILL.md
40.7 KB, as published. Nobody here has run it
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=truepersists 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 —
previewand any custom environment (e.g. a named "staging"). The signal isVERCEL_TARGET_ENV(falling back toVERCEL_ENVwhen absent), NOTVERCEL_ENV:VERCEL_ENVonly ever reportsproduction/preview/developmentand collapses every custom environment into one of those buckets, so a custom target can readVERCEL_ENV=productionand slip through ungated.VERCEL_TARGET_ENVcarries the custom name. It fails open when that value isproductionordevelopment; 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 runsNODE_ENV=production, so a configured one there still gates (see the Mode B lifecycle note). - No valid cookie → responds
401with an inline HTML password form (no extra routes/pages added to the app). Form POSTs to/__deploy-unlock. - Human auth:
DEPLOY_GATE_PASSWORD_HASHstoress2:<salt>:<scryptHex>(scrypt, memory-hard) — never the plaintext. Submitted passwords are run through scrypt and compared constant-time. (Legacy fallback: a plaintextDEPLOY_GATE_PASSWORDalso works.) - Automation auth (mimics Vercel's Protection Bypass for Automation):
DEPLOY_GATE_BYPASS_TOKENSstores JSON{"<label>":"<token>", ...}— plaintext by design (automation must read tokens back; they're generated random, never human-reused). Send a token via thex-deploy-gate-bypassheader (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. - Unlock cookies are HMACs keyed on the credential that minted them:
rotating the password kills password-issued cookies; removing a bypass token
kills that token's cookies.
maxAge1 year → "unlocks once, stays unlocked". - Absent config fails open (a fresh clone never bricks its previews);
present-but-malformed
DEPLOY_GATE_PASSWORD_HASHfails CLOSED (503) — a typo must not silently publish a preview the operator meant to protect. A legacyDEPLOY_GATE_PASSWORDlonger than the 256-char cap also fails closed (it would hash into a config the unlock form's length cap can never match — gated with no way in), and the 503 body names both causes. Malformed bypass-token JSON is ignored with a warning (password still works).
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 toproxy.tsfirst (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.tsruns on the Edge runtime even in Next 16, wherenode:cryptodoes 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.tsnext to your host proxy file and import it relatively. If the host proxy is at the project root (proxy.ts), put the helper atlib/deploy-gate.ts; if the host uses asrc/layout (src/proxy.ts), put it atsrc/lib/deploy-gate.ts— a root./lib/deploy-gateimport fromsrc/proxy.tsresolves tosrc/lib/…and won't find a rootlib/, 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 localhttp://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
setCookiepath 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/apiin the matcher and skip only the host's page-routing logic for API paths. -
Do NOT wire up the removal script — the host middleware must ship to production for its own duties.
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.tstoproxy.tsat the same level as yourapp/pagesdirectory — the project root, orsrc/proxy.tsif the app uses asrc/directory. ⚠️ Next only loads the proxy at that level: a rootproxy.tsin asrc/-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 customisespageExtensions(e.g..page.ts), Next expects the proxy named to match —proxy.page.ts— per Next's proxy docs; a plainproxy.tsis ignored (same silent-ungate). Name the file accordingly and point the removal script'sCANDIDATESat it. -
Copy
templates/remove-proxy-on-prod.mjstoscripts/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 asrc/layout needs no edit; if the gate lives somewhere else, add that path to itsCANDIDATESarray. -
Chain it into the build (explicit chaining, not an npm
prebuildhook — 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/developmentVercel target, and on a local dev server — detected byNODE_ENV === "development"(whichnext devand 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, noVERCEL_*var reaches the runtime, so the target readsundefined— butNODE_ENVisproductionthere (it isn't one of the toggle-gatedVERCEL_*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 needsVERCEL=1, so it goes inert and production ships the (no-op) middleware — you lose the zero-cost property; and (2)DEPLOY_GATE_UNPROTECTED_HOSTSstops 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 theproductionarm from the pass-through condition, but keep the stock local-dev guard exactly — do NOT pass on a baretarget === undefined. A real Vercel deployment with System Environment Variables disabled also reportsundefined, 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 thenode 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: 100dvhcentering, 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 computedaction(UNLOCK_PATH + encodedfrom),name="password", the failed-state error message,<meta name="robots" content="noindex">,autofocus,autocomplete="current-password", andescapeHtml()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 inpsoutput — 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 previewUse
vercel env updateto rotate, notrmthenadd: between anrmand 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 thes2:…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.
- The stored hash is itself a bearer secret — treat a leak like a leaked
password. The unlock cookie is
HMAC(key = the hash, "deploy-gate:unlocked:v1"), a fixed public message, so anyone who obtainsDEPLOY_GATE_PASSWORD_HASH(a copied dashboard value, a CI log) can forge a valid cookie without ever recovering the plaintext — scrypt's memory-hardness only protects the plaintext, not access. So keep the hash out of logs, and rotate it on any suspected disclosure, not just a plaintext leak. (Fine for a speed bump; just don't reuse a real password, and don't treat the hash as safe to expose.)
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 adddefaults 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 —addmints 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-backaddwrites the new map to stdout and the generated token + summary to stderr; show that token to the user once. It refuses duplicate labels — rotate byremove+add. -
Write back the
$NEWmap you captured in step 2 — pipe that exact JSON, don't re-run the helper (a secondaddwould 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-sensitiveis required so step 1 can read the map back next time (see the warning above).updateavoids therm→addwindow 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_SECRETconvention: designate one token (e.g.ci) and store it as a CI secret namedDEPLOY_GATE_BYPASS_SECRETfor workflows to read.
- Header (CI, Playwright, curl):
-
Revocation semantics: removing a token invalidates its cookies on new deployments immediately (cookies are keyed per-token) — but as with the password, already-deployed previews honor the old env until redeployed. Removing the last token on a token-only deployment (no password hash) leaves the var as
{}, which now fails closed (503) rather than publishing — to make such a deployment public, unset the var entirely (vercel env rm DEPLOY_GATE_BYPASS_TOKENS preview --yes), don't empty it.
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.comdoes not unprotectdemo.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_HASHis malformed — otherwise the fail-closed 503 would take down a domain the operator explicitly marked public. - Bare hostnames only —
demo.acme.com, nothttps://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
Hostheader, 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 knownVERCEL_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'sHost,Hostis 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
matcherexcludes 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), butvercel env add … previewdoesn't reach them — each custom environment is its own scope. AddDEPLOY_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.tsis Node-runtime-only, which is what the template assumes (node:crypto). On Next ≤15 rename the file tomiddleware.ts, the export tomiddleware(and the removal-script target to match); if it runs on the edge runtime, replacenode:cryptowith Web Crypto (crypto.subtle.digest/sign+ a manual XOR-fold compare) — edge has nonode:crypto. - This is a speed bump, not auth. One shared password + machine tokens, no
user identity, no rate limiting. Never use it as access control for real user
data, accounts, or payments. Gating production is supported (see "Gating
production too") but only for low-stakes surfaces — a coming-soon page, a
client demo, an internal tool. If the thing behind the wall would be a breach,
use Vercel Authentication, Clerk, or a real IdP. The expensive scrypt check runs only on
explicit form POSTs to
/__deploy-unlock(input capped at 256 chars); every per-request check — cookie, bypass tokens — is a cheap constant-time compare, so the gate itself is not a CPU amplifier. formData()in the proxy consumes the request body — fine here because a locked-out visitor's POST never reaches the app anyway.- Turbo/monorepo caches: the removal script mutates the app dir before
next build; make sureVERCEL_TARGET_ENV(and itsVERCEL_ENVfallback) participates in the build's cache key — a custom-env build and a true-prod build can shareVERCEL_ENV=productionyet differ in whether the proxy ships. On Vercel this works via environment separation; for custom Turborepo remote caching, add both to the task'senvlist.
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.