Site PIN gate: one PIN in front of the whole site
A PIN gate is the smallest possible access control: one shared secret, one
cookie, zero user accounts. Its whole value is that the app behind it never
knows it exists. The insight that shapes this module is that everything
dangerous lives in the two places the gate touches the outside world: the
return path it redirects to, and the cookie it hands out. Everything else is a
form and a cookie check.
Written by the engineer who has shipped this module. The earlier implementation
it was audited against was a temporary gate on a marketing site kept private
ahead of its launch. The facts and rules below are what the behaviour contract
and the handler suite verify; references/provenance.md has the record.
When to use
A public-by-default Next.js site that must be hidden for a while: before
launch, during a client or investor review, on a staging domain. Everyone who
should see it can be told one PIN. The gate sits in proxy.ts (Next 16) or
middleware.ts (Next 13 to 15) and is removed by unsetting one env var.
When NOT to use
| Instead of this |
Use |
| Real users with real accounts |
the host's auth (Clerk, NextAuth, Supabase Auth); a shared PIN cannot be revoked for one person |
| Per-route or per-tenant authorization |
the host's authorization layer; this gate is all-or-nothing by path matcher |
| Only preview deployments, on Vercel, for team members |
Vercel Deployment Protection, which needs no code; see operations.md |
| Protecting an API consumed by machines |
an API key; a cookie and an HTML form are the wrong shape |
Architecture
request --> proxy.ts / middleware.ts (the matcher decides what is even seen)
|
+- SITE_PIN unset ...........................> app (gate off)
|
+- cookie == HMAC(secret, pin) ..............> app
|
+- GET /__unlock ..........> 303 /
+- POST /__unlock bad body > 400 gate page
| over budget 429 gate page + Retry-After
| wrong PIN . 401 gate page (return path kept)
| right PIN . 303 safe return path + Set-Cookie
|
+- anything else ............> 401 gate page, no-store, noindex
Critical facts
- An unset PIN is an open site, silently. The most likely production
failure is the env var set in one environment and not another. Smoke-test
every environment; the check is one
curl.
- The matcher is the security boundary. Whatever it excludes is public.
Build assets always are; the sitemap,
robots.txt, OG images and /api are
public only if you exclude them, and a pages-only matcher publishes the route
list of an unlaunched site through sitemap.xml.
- The cookie is a bearer token derived from the PIN. Rotating the PIN logs
every browser out with no store to clear. Without
SITE_GATE_SECRET it is a
plain fingerprint of the PIN, and a leaked cookie is then a leaked PIN.
- The PIN's length is the real defence. The attempt budget is per server
instance and best-effort. A four-digit PIN is ten thousand requests.
- The gate page is self-contained by design. The proxy runs before the
app, with no stylesheet, no components and no i18n provider. Inline CSS and a
strings table are not laziness.
Hard rules
Never validate a return path with startsWith('/'). //evil.example,
/\evil.example and ///evil.example all pass it and all leave the site.
Resolve the value against the request origin and compare origins.
Never answer a successful form POST with a 307. The browser repeats the
request with its method and body, so the PIN is posted again to the page it
lands on, and once more through any locale redirect. Use 303.
Never put the PIN, or an unkeyed hash of it, in the cookie. A cookie the
operator can read is a cookie an operator's laptop can leak. Key the token
with SITE_GATE_SECRET.
Never compare the PIN or the cookie with ===. Route both through a
constant-time comparison of equal-length digests.
Never let request.formData() throw to the platform. It rejects any body
that is not form-encoded, and an unhandled rejection in the proxy is a stack
trace instead of a rejected attempt. Answer 400.
Never choose the matcher by default, and never mount the unlock path under
a locale prefix or an app route. What the matcher excludes is public; a
colliding unlock path would never render, because the gate answers it first.
Invocation
| Invocation |
Meaning |
/site-pin-gate |
Read the task, probe the host, ask for the PIN at step 4 |
/site-pin-gate 1111 |
One bare token is the PIN: use it as SITE_PIN, do not ask |
/site-pin-gate uninstall |
Reserved word, never a PIN: remove the wiring, lib/site-gate and the env vars; the site goes public |
/site-pin-gate audit middleware.ts |
More than one token is a task, not a PIN |
Name the PIN back before writing it, so a single word meant as a topic
("staging") is not silently armed as one. Trimmed, it must be 1 to 128
characters: blank arms nothing, and the form caps its field at 128, so a longer
PIN is a gate no browser can open. Under eight characters is enumerable and
1111 is ten thousand guesses against a per-instance, best-effort budget
(fact 4): use it, say what it is worth, and that rotating it is one deploy.
The PIN goes to .env.local and nowhere else: never a template, a test, a
commit or .env.example; deployed environments take theirs from the platform's
env store (operations.md). SITE_GATE_SECRET is
not the argument's business: openssl rand -hex 32, never derived from the PIN.
uninstall is a launch, so confirm the site is meant to be public, then follow
Uninstalling in operations.md. To lock again
later, keep the code and unset SITE_PIN instead.
Quick start
- Probe the host and fill in the seam table:
adaptation.md.
- Copy the config, pure helpers and attempt store:
module.md.
- Copy the page renderer and the handler, wire them into
proxy.ts or
middleware.ts, and choose the matcher: handler.md.
- Set
SITE_PIN (the invocation's PIN, if one was given) and
SITE_GATE_SECRET per environment, then run the smoke checks:
operations.md.
- Run the shipped tests in the host's runner:
testing.md.
Reference directory
| Scenario |
Trigger keywords |
Reference |
| Fitting this into an existing app |
adapt, seam, proxy.ts, middleware.ts, matcher, next-intl, cookie name, unlock path, strings, Redis |
adaptation.md |
| Config, token, return path, budget |
SITE_PIN, SITE_GATE_SECRET, HMAC, constantTimeEqual, safeReturnPath, open redirect, rate limit, x-forwarded-for |
module.md |
| The behaviour contract, the page, the handler, the wiring |
renderGatePage, createSiteGate, 303, 401, 429, formData, Set-Cookie, x-robots-tag, inline CSS |
handler.md |
| Running it in production |
env per environment, PIN at invocation, .env.local, Vercel, rotate PIN, kill switch, uninstall, remove the gate, launch, smoke test, sitemap, robots, OG image, /api, Deployment Protection, lock endpoint, multiple PINs |
operations.md |
| Proving it |
vitest, bun test, NextRequest, test cases |
testing.md |
| The audit ledger: what changed, was kept and was added |
provenance, defect, audit, kept deliberately, upgrading an existing gate |
provenance.md |
Part of the Timerise Skills index, which lists the sibling skills.
1---2name: site-pin-gate3description: Put a shared-PIN gate in front of an entire Next.js site from the proxy/middleware layer: one env var arms it, a self-contained unlock page sets an HMAC-derived HttpOnly cookie, and the app behind it stays untouched. Use when: (1) a pre-launch, staging, client-preview or investor-preview site must be hidden from the public and from crawlers without adding user accounts, (2) an existing PIN or password gate in middleware.ts or proxy.ts needs auditing, (3) the user mentions: site PIN, password-protect the site, access code, pre-launch gate, coming-soon lock, staging password, SITE_PIN, "hide the site behind a PIN", "lock the preview deployment", "temporary password page". Carries the return path resolved against the request origin, the 303 unlock redirect, the cookie token keyed by a server-side secret, constant-time comparisons, an attempt budget answering 429 with Retry-After, and the suite that holds them. Next.js App Router, proxy.ts or middleware.ts; the attempt store is a seam, so a shared store can repla4---56# Site PIN gate: one PIN in front of the whole site78A PIN gate is the smallest possible access control: one shared secret, one9cookie, zero user accounts. Its whole value is that the app behind it never10knows it exists. The insight that shapes this module is that **everything11dangerous lives in the two places the gate touches the outside world**: the12return path it redirects to, and the cookie it hands out. Everything else is a13form and a cookie check.1415Written by the engineer who has shipped this module. The earlier implementation16it was audited against was a temporary gate on a marketing site kept private17ahead of its launch. The facts and rules below are what the behaviour contract18and the handler suite verify; `references/provenance.md` has the record.1920## When to use2122A public-by-default Next.js site that must be hidden for a while: before23launch, during a client or investor review, on a staging domain. Everyone who24should see it can be told one PIN. The gate sits in `proxy.ts` (Next 16) or25`middleware.ts` (Next 13 to 15) and is removed by unsetting one env var.2627## When NOT to use2829| Instead of this | Use |30|---|---|31| Real users with real accounts | the host's auth (Clerk, NextAuth, Supabase Auth); a shared PIN cannot be revoked for one person |32| Per-route or per-tenant authorization | the host's authorization layer; this gate is all-or-nothing by path matcher |33| Only preview deployments, on Vercel, for team members | Vercel Deployment Protection, which needs no code; see [operations.md](references/operations.md) |34| Protecting an API consumed by machines | an API key; a cookie and an HTML form are the wrong shape |3536## Architecture3738```39request --> proxy.ts / middleware.ts (the matcher decides what is even seen)40 |41 +- SITE_PIN unset ...........................> app (gate off)42 |43 +- cookie == HMAC(secret, pin) ..............> app44 |45 +- GET /__unlock ..........> 303 /46 +- POST /__unlock bad body > 400 gate page47 | over budget 429 gate page + Retry-After48 | wrong PIN . 401 gate page (return path kept)49 | right PIN . 303 safe return path + Set-Cookie50 |51 +- anything else ............> 401 gate page, no-store, noindex52```5354## Critical facts55561. **An unset PIN is an open site, silently.** The most likely production57 failure is the env var set in one environment and not another. Smoke-test58 every environment; the check is one `curl`.592. **The matcher is the security boundary.** Whatever it excludes is public.60 Build assets always are; the sitemap, `robots.txt`, OG images and `/api` are61 public only if you exclude them, and a pages-only matcher publishes the route62 list of an unlaunched site through `sitemap.xml`.633. **The cookie is a bearer token derived from the PIN.** Rotating the PIN logs64 every browser out with no store to clear. Without `SITE_GATE_SECRET` it is a65 plain fingerprint of the PIN, and a leaked cookie is then a leaked PIN.664. **The PIN's length is the real defence.** The attempt budget is per server67 instance and best-effort. A four-digit PIN is ten thousand requests.685. **The gate page is self-contained by design.** The proxy runs before the69 app, with no stylesheet, no components and no i18n provider. Inline CSS and a70 strings table are not laziness.7172## Hard rules7374> **Never validate a return path with `startsWith('/')`.** `//evil.example`,75> `/\evil.example` and `///evil.example` all pass it and all leave the site.76> Resolve the value against the request origin and compare origins.7778> **Never answer a successful form POST with a 307.** The browser repeats the79> request with its method and body, so the PIN is posted again to the page it80> lands on, and once more through any locale redirect. Use 303.8182> **Never put the PIN, or an unkeyed hash of it, in the cookie.** A cookie the83> operator can read is a cookie an operator's laptop can leak. Key the token84> with `SITE_GATE_SECRET`.8586> **Never compare the PIN or the cookie with `===`.** Route both through a87> constant-time comparison of equal-length digests.8889> **Never let `request.formData()` throw to the platform.** It rejects any body90> that is not form-encoded, and an unhandled rejection in the proxy is a stack91> trace instead of a rejected attempt. Answer 400.9293> **Never choose the matcher by default, and never mount the unlock path under94> a locale prefix or an app route.** What the matcher excludes is public; a95> colliding unlock path would never render, because the gate answers it first.9697## Invocation9899| Invocation | Meaning |100|---|---|101| `/site-pin-gate` | Read the task, probe the host, ask for the PIN at step 4 |102| `/site-pin-gate 1111` | One bare token is **the PIN**: use it as `SITE_PIN`, do not ask |103| `/site-pin-gate uninstall` | Reserved word, never a PIN: remove the wiring, `lib/site-gate` and the env vars; the site goes public |104| `/site-pin-gate audit middleware.ts` | More than one token is a task, not a PIN |105106Name the PIN back before writing it, so a single word meant as a topic107("staging") is not silently armed as one. Trimmed, it must be 1 to 128108characters: blank arms nothing, and the form caps its field at 128, so a longer109PIN is a gate no browser can open. Under eight characters is enumerable and110`1111` is ten thousand guesses against a per-instance, best-effort budget111(fact 4): use it, say what it is worth, and that rotating it is one deploy.112113The PIN goes to `.env.local` and nowhere else: never a template, a test, a114commit or `.env.example`; deployed environments take theirs from the platform's115env store ([operations.md](references/operations.md)). `SITE_GATE_SECRET` is116not the argument's business: `openssl rand -hex 32`, never derived from the PIN.117118`uninstall` is a launch, so confirm the site is meant to be public, then follow119*Uninstalling* in [operations.md](references/operations.md). To lock again120later, keep the code and unset `SITE_PIN` instead.121122## Quick start1231241. Probe the host and fill in the seam table:125 [adaptation.md](references/adaptation.md).1262. Copy the config, pure helpers and attempt store:127 [module.md](references/module.md).1283. Copy the page renderer and the handler, wire them into `proxy.ts` or129 `middleware.ts`, and choose the matcher: [handler.md](references/handler.md).1304. Set `SITE_PIN` (the invocation's PIN, if one was given) and131 `SITE_GATE_SECRET` per environment, then run the smoke checks:132 [operations.md](references/operations.md).1335. Run the shipped tests in the host's runner:134 [testing.md](references/testing.md).135136## Reference directory137138| Scenario | Trigger keywords | Reference |139|---|---|---|140| Fitting this into an existing app | adapt, seam, proxy.ts, middleware.ts, matcher, next-intl, cookie name, unlock path, strings, Redis | [adaptation.md](references/adaptation.md) |141| Config, token, return path, budget | SITE_PIN, SITE_GATE_SECRET, HMAC, constantTimeEqual, safeReturnPath, open redirect, rate limit, x-forwarded-for | [module.md](references/module.md) |142| The behaviour contract, the page, the handler, the wiring | renderGatePage, createSiteGate, 303, 401, 429, formData, Set-Cookie, x-robots-tag, inline CSS | [handler.md](references/handler.md) |143| Running it in production | env per environment, PIN at invocation, .env.local, Vercel, rotate PIN, kill switch, uninstall, remove the gate, launch, smoke test, sitemap, robots, OG image, /api, Deployment Protection, lock endpoint, multiple PINs | [operations.md](references/operations.md) |144| Proving it | vitest, bun test, NextRequest, test cases | [testing.md](references/testing.md) |145| The audit ledger: what changed, was kept and was added | provenance, defect, audit, kept deliberately, upgrading an existing gate | [provenance.md](references/provenance.md) |146147Part of the [Timerise Skills](https://github.com/timerise-ai/skills) index, which lists the sibling skills.