agentsclimarketplace

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

Install
npx -y skills add stealth-engine/skills --skill vercel-deployment-password-gate

Assembled 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 ProtectionThis 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

NeedRight tool
Only the Vercel team views previewsVercel Authentication (Deployment Protection → Standard). Free on all plans, zero code, team members pass invisibly via their Vercel login. Prefer this when it fits.
External stakeholdersVercel 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 freeThis 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 brandingThis 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 VercelStill 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:

  1. 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).
  2. 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:

ProjectTemplateInstalls as
Next.jstemplates/deploy-gate.ts — no deps beyond next/server + node:cryptoproxy.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.
  • 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. maxAge 1 year → "unlocks once, stays unlocked".
  • Absent config fails open (a fresh clone never bricks its previews); present-but-malformed DEPLOY_GATE_PASSWORD_HASH fails CLOSED (503) — a typo must not silently publish a preview the operator meant to protect. A legacy DEPLOY_GATE_PASSWORD longer 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:

PurposeCurrent nameLegacy alias (still honoured)
Password hashDEPLOY_GATE_PASSWORD_HASHPREVIEW_PASSWORD_HASH
Plaintext password (legacy scheme)DEPLOY_GATE_PASSWORDPREVIEW_PASSWORD
Automation bypass tokensDEPLOY_GATE_BYPASS_TOKENSPREVIEW_GATE_BYPASS_TOKENS
Unprotected-host allowlistDEPLOY_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.
  1. 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.

  2. 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).

  3. 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 /api in the matcher and skip only the host's page-routing logic for API paths.

  4. 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:

  1. 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.

  2. 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.

  3. 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"
    
  4. 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:

ContextWhat happens
Local next dev / a Vite dev server (NODE_ENV=development), vercel dev (VERCEL_ENV=development), non-Vercel hostsGate 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) buildFile 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 buildScript 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:

  1. 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.
    
  2. 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.)

  3. 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:

  1. 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.

  2. 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.

  3. 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.

  4. 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 obtains DEPLOY_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 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.

  1. 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
    
  2. 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.

  3. 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 rmadd window where the var is absent and the gate fails open.

  4. 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.
  5. 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.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 onlydemo.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_gatedeploy_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-bypassx-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 middleware.ts, the export to middleware (and the removal-script target to match); if it runs on the edge runtime, replace node:crypto with Web Crypto (crypto.subtle.digest/sign + a manual XOR-fold compare) — edge has no node: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 sure VERCEL_TARGET_ENV (and its VERCEL_ENV fallback) participates in the build's cache key — a custom-env build and a true-prod build can share VERCEL_ENV=production yet differ in whether the proxy ships. On Vercel this works via environment separation; for custom Turborepo remote caching, add both to the task's env list.

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.

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.