Infrastructure & security

Site PIN gate

A shared-PIN gate in front of a whole Next.js site, from the proxy layer: one environment variable arms it, an unlock page sets an HMAC cookie, and the app behind it stays untouched.

Released
September 27, 2026
npx skills add timerise-ai/site-pin-gate
v0.3.4
Current release
6
Reference docs
6
Non-negotiables
MIT
License

The problem

What problem does Site PIN gate solve?

A site that is not ready for the public still has to be seen: by the client, by investors, by a reviewer. User accounts are too much for that. A shared PIN is enough, as long as the gate itself is safe.

The dangerous parts of a PIN gate are small and easy to get wrong: where the unlock page redirects, and how the cookie is made. An open redirect or a guessable cookie turns a locked preview into a public one.

This skill puts the gate in front of the whole site from the proxy layer. One environment variable arms it. The unlock page sets a cookie derived from a server-side secret. Comparisons run in constant time, failed attempts get a budget, and the app behind it never knows the gate exists.

The module

What does the skill build?

An agent with this skill builds one module for a Next.js App Router app, on your stack. For Site PIN gate that module consists of:

  • 1. One env var arms it
  • 2. An unlock page sets an HMAC cookie
  • 3. Origin-resolved return path
  • 4. Attempt budget answering 429

Provenance

Where do the rules come from?

This skill was 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 templates hold the properties a gate has to hold: an unlock that can only return to the site's own origin, a redirect the browser follows as a GET so the PIN is never sent twice, a cookie that is opaque to anyone without the server's secret, comparisons that take the same time whatever the input, a request body that is answered rather than thrown, and a failed-attempt budget that answers 429. The behaviour contract and the handler suite state each one; `references/provenance.md` has the record.

The earlier implementation this module was audited against was a temporary site-wide PIN gate on a multi-locale App Router marketing site, kept private ahead of its launch: a self-contained block in proxy.ts, run before locale routing, with a SITE_PIN env var, a cookie holding a SHA-256 of the PIN, and an inline HTML form posting to /__unlock. The architecture here is that block's. The templates are not a transcription of it, and the ledger below is why.

The ledger separates what the audit changed, what was kept on purpose, and what has not run in production yet.

  1. Fixed in the templates

    • The unlock form redirected off-site (verified live) The return path was accepted if it started with /. //evil.example/x starts with /. Posting the correct PIN with that as next produced location: http://evil.example/x; /\evil.example and ///evil.example behave the same in the URL parser. A page anywhere could auto-submit a wrong PIN with a hostile next, let the victim see the real gate on the real domain, and collect them on the other side.
    • The cookie was the PIN in disguise (verified live) The cookie held SHA-256("<constant>:" + PIN), with the constant in the code. Numeric PINs are what a field marked inputmode="numeric" invites. A ten-thousand-iteration loop recovered the PIN from a captured cookie in milliseconds. The comment above the code said the raw PIN "never sits in the browser cookie", which was true and beside the point.
    • A successful unlock re-POSTed the PIN to the landing page (verified live) NextResponse.redirect defaults to 307, which preserves method and body. The dev server log showed POST /pl/robot 200 after the unlock: the page rendered from a POST carrying pin=1234 in its body, and with a locale redirect chain the body travels once more. Reloading the landing page in a browser prompts to resubmit the form.
    • A non-form body crashed the proxy (verified live) request.formData() throws a TypeError for any body that is not multipart/form-data or application/x-www-form-urlencoded. It was not caught; a POST with a JSON body returned a 500 page.
    • No attempt budget Nothing counted failures. A four-digit PIN is ten thousand requests to a handler that costs a hash each; a laptop does that in minutes. The host app had a rate-limit helper used by its API routes; the gate did not call it.
    Show 7 more
    • Plain === on the PIN and on the cookie Both comparisons short-circuit on the first differing character.
    • The pages-only matcher published the route list (verified live) The matcher excluded /api and every path with a dot. Without a cookie, /sitemap.xml returned 200 and listed every URL of the unlaunched site; the OG image and the API routes were reachable as well.
    • The digest was recomputed on every request gateToken(SITE_PIN) ran once per request for a value that never changes.
    • One language, brand hardcoded, on a multi-locale site The gate page carried the site's brand as a literal and spoke one language to every visitor, whose locale cookie was right there in the request.
    • A whitespace PIN armed a gate nobody could open SITE_PIN=" " passed the truthiness check, but submitted PINs were trimmed, so nothing ever matched.
    • Return path escaped for " only The hidden field escaped double quotes and nothing else. In a double-quoted attribute that is enough to stay inside the attribute, so no exploit was found; it is still one function away from correct.
    • status: error ? 401 : 401 Cosmetic; both branches were 401. Recorded because it is the kind of line that gets "fixed" into a wrong status. 401 on both is right.
Read the full record in provenance.md

Non-negotiables

What are the 6 rules the module never breaks?

Every module built from this skill holds these, whoever builds it. The same list is in the skill's README and SKILL.md, so the agent reads it before it writes a line.

  1. The return path is resolved against the request origin

    , never checked with startsWith('/'). A path that begins with a slash can still leave the site, so the sanitiser resolves the value and compares origins. Three off-origin cases are in the suite.

  2. The unlock redirect is a 303.

    A 307 tells the browser to repeat the request with its method and body, so the PIN would be posted again to the landing page, and once more through any locale redirect. The status is asserted on the success path.

  3. The cookie is an HMAC keyed by a server-side secret

    , not the PIN and not an unkeyed hash of it. A cookie an operator can read is a cookie an operator's laptop can leak. A token derived without the secret is rejected in the suite.

  4. Both comparisons are constant-time

    over equal-length digests, so neither the PIN check nor the cookie check leaks its answer through how long it took.

  5. A body that is not a form is answered, never thrown.

    request.formData() rejects anything that is not form-encoded, and an unhandled rejection in the proxy is a stack trace instead of a rejected attempt. The 400 path is in the suite.

  6. The matcher is chosen on purpose, and the unlock path sits outside every locale prefix and app route.

    Whatever the matcher excludes is public, including the sitemap and OG images unless you say otherwise. Build assets stay outside it: gating _next/static hides nothing from a crawler, which never holds the cookie. A colliding unlock path would never render, because the gate answers it before routing.

Fit

When should you use it, and when not?

Use it for

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

Not for

  • Real users with real accountsInsteadThe host's auth: Clerk, NextAuth, Supabase Auth. A shared PIN cannot be revoked for one person
  • Per-route or per-tenant authorizationInsteadThe host's authorization layer; this gate is all-or-nothing by path matcher
  • Locking preview deployments for team members on VercelInsteadVercel Deployment Protection, which does it with no code. The comparison is in references/operations.md
  • Protecting an API consumed by machinesInsteadAn API key; a cookie and an HTML form are the wrong shape

Build it yourself

How do I install it?

One command. The skills.sh CLI installs the skill into every skills-compatible agent it finds.

$ npx skills add timerise-ai/site-pin-gate

Claude Code

Invoke with /site-pin-gate

Codex CLI

Invoke with $site-pin-gate

Gemini CLI

Invoke with /skills

Name the agents instead with -a, for example npx skills add timerise-ai/site-pin-gate -a claude-code -a codex. Or clone the repository into your agent's skills folder. Nothing in it is agent-specific.

What is inside the repository (8 entries)
  • SKILL.mdEntry point: architecture diagram, critical facts, hard rules, quick start, and the reference directory
  • references/adaptation.mdThe seam contract with the host app: proxy versus middleware, the matcher, strings, styling, cookie name, a shared attempt store
  • references/module.mdConfig, token derivation, constant-time compare, the return-path sanitiser, the attempt store
  • references/handler.mdThe behaviour contract, the gate page, the request handler, the proxy wiring
  • references/operations.mdEnv vars per environment, a PIN given at invocation, smoke checks, rotation, kill switch, uninstalling, what stays public, extensions
  • references/testing.mdThe two test files, 36 tests, and how to run them under vitest or bun
  • references/provenance.mdThe engineering ledger: what the audit of the earlier implementation changed and how the templates verify it, what was kept on purpose, and what is new in the skill
  • evals/The prompts an operator types after installing (prompts.md) and one file per agent eval: the skill installed into an empty Next.js app, one prompt, no help, then type-checked, built and tested

Recent releases

  1. v0.3.4September 27, 2026

    Fix release, from reading the prompt-1 agent eval runs against 0.3.3.

  2. v0.3.3September 27, 2026

    Fix release, from reading the prompt-1 agent eval runs against 0.3.2.

  3. v0.3.2September 27, 2026

    Documentation release. The skill content is unchanged from 0.3.1.

After installing

What do I tell my agent?

Say what you need in your own words; the skill supplies the how. These are starting points, and the ones we tested say how it went.

  1. Hide this whole site behind a PIN until launch. One environment variable turns it on, and search engines must not index anything while it is on.

  2. Lock our staging deployment for a client review with a shared PIN, and slow down anyone who keeps guessing wrong.

    No data store
  3. Audit the password gate in our middleware.ts.

Tested

How does it do in each agent?

We install the skill into an empty Next.js app, give the agent one of the prompts above and no further help, then type-check, build and run the tests it left behind. Nothing is fixed by hand before the checks, and a failing run is published like a passing one. The procedure and every result are public, and the first prompt runs again before each release.

  • Gemini CLI0.61.0

    gemini-3.8-flash

    Built, checks pass
    Hide this whole site behind a PIN until launch. One environment variable turns it on, and search engines must not index anything while it is on.
    Typecheck: passBuild: passTests: pass
    Time
    3 min
    Changed
    13 files, +1,949 lines
    Stack
    No data store
    Skill
    v0.3.4
    Run
    Sep 27, 2026

    Result fileAgent log

  • Codex CLIcodex-cli 0.157.1

    gpt-6-sol

    Built, checks pass
    Hide this whole site behind a PIN until launch. One environment variable turns it on, and search engines must not index anything while it is on.
    Typecheck: passBuild: passTests: pass
    Time
    3 min
    Changed
    13 files, +881 lines
    Stack
    No data store
    Skill
    v0.3.4
    Run
    Sep 27, 2026

    Result fileAgent log

  • Claude Code2.1.283

    claude-opus-5-5

    Built, checks pass
    Hide this whole site behind a PIN until launch. One environment variable turns it on, and search engines must not index anything while it is on.
    Typecheck: passBuild: passTests: pass
    Time
    1 min
    Changed
    14 files, +1,963 lines
    Stack
    No data store
    Skill
    v0.3.4
    Run
    Sep 27, 2026

    Result fileAgent log

Build it with Timerise

How long does it take, and what does it cost?

We quote this module per project. The price depends on what it has to connect to. The path to a number is short and free:

  1. Step 1

    Brief

    Tell us what the module must connect to. Takes minutes, in a chat.

  2. Step 2

    Prototype in 48 hours

    A clickable prototype of your system and a quote, at no cost.

  3. Step 3

    Build and handoff

    One project price. Source code, documentation and IP are yours.

Two ways to get Site PIN gate

Build it yourself

Install the skill. Your own agent builds the module.

  • MIT licensed, no strings
  • Runs in Claude Code, Codex CLI and Gemini CLI
  • The same rules our engineers build by
$ npx skills add timerise-ai/site-pin-gate

Build it with Timerise

Send a brief. We build Site PIN gate into a system you own.

  • Clickable prototype and a quote within 48 hours, free
  • One project price, no subscription, no commission
  • Source code, documentation and IP handed over
3 years of support included.

Generated from the skill's own files at commit 636bf69. Every rule above links to where the repository says it. All skills