The honest security model.
Knock Codes stops casual visitors, search engines, and forwarded links. Local mode does not stop anyone who opens DevTools — the hash ships in your client bundle by design. Server mode (swap one prop) hides the hash from the client; children you already bundled are still in the JavaScript, and a forged session works unless you wire validateSession. A velvet rope, with an optional real lock. Never marketed as more than that.
What local mode actually does
Client-side SHA-256 verification and session storage trade-offs.
Every default template and block verifies a code against a SHA-256 hash you pass as expectedHash. That comparison runs entirely in the visitor's browser — there's no server, no network round trip, nothing to stand up. The trade-off: the hash itself ships in your client JavaScript bundle. Anyone who opens DevTools can read it, copy it, and run it through an offline cracker at their leisure. Local mode was never designed to survive that — it's a velvet rope for people who won't open DevTools in the first place, which is most visitors, most of the time.
The optional remember="session"prop works the same way: it stores an unlock marker in sessionStorage so a reload skips the gate for the rest of that tab's session. It's client-side persistence, clearable from DevTools or a private window — not a security boundary, just a convenience on top of the same trust model.
Local mode vs. server mode
A side-by-side breakdown of verification location, visibility, brute-force protection, and setup.
| Dimension | Local mode | Server mode |
|---|---|---|
| Where the check happens | In the visitor's browser, against a hash you shipped in the client bundle. | On your server or edge function, against a hash that never reaches the browser. |
| What a curious visitor can see | The full SHA-256 hash, in DevTools → Sources/Network, for anyone who looks. | The hash stays off the client. Children you already bundled are still in the JavaScript. A stored session can be forged unless you pass validateSession. |
| Stored session | A client-writable {unlockedAt, expiresAt} unless you pass validateSession. | Same client storage. Wire validateSession to POST the token on restore, or a forged session still unlocks. |
| Can it be brute-forced offline? | Yes — the hash is public, so guessing is only limited by compute, not by your app. | No — guesses have to go through your endpoint, where you can rate-limit them. |
| Rate limiting | Not possible — there's no request to limit. | Yes — the reference templates cap attempts per identifier per time window. |
| Setup | None. Paste a template, drop in a hash. | One prop swap (`expectedHash` → `verify`) plus a small endpoint you deploy. |
| Best for | Keeping casual visitors, crawlers, and forwarded links off a preview. | Anywhere a determined visitor bypassing the gate would actually matter. |
Where Knock Codes belongs
When a shared restricted access screen fits your use case, and when to require user accounts.
Appropriate
- A client preview or staging deploy you don't want indexed or casually forwarded.
- A private beta or "coming soon" screen with no real per-user permissions.
- An internal dashboard or tool where everyone with the code is already trusted.
- A launch checklist or demo you want gone in minutes, not wired into your auth system.
Not appropriate
- Paid content — a shared code has no concept of "did this visitor pay."
- Private user data — anything scoped to one person needs real accounts, not a shared code.
- Admin authorization or any real write path — pair with your actual auth system instead.
- Compliance-sensitive data (health, financial, PII at rest) — local mode's hash is public by design.
Upgrading to server verification
Swap expectedHash for a verify function pointing at one of these reference endpoints — same markup, same component, one prop different.
// @ts-check
/**
* Knock Codes — Next.js route handler server verification template.
* Drop at app/api/verify-access/route.js (App Router).
*
* Set two env vars: KNOCK_CODES_SERVER_HASH, KNOCK_CODES_TOKEN_SECRET.
*
* Wire contract identical across all server templates — see the
* Cloudflare Worker template in this same folder for the full contract.
*
* Session restore (pair with `validateSession` on useKnockCodes / the
* provider). A stored `{ unlockedAt, expiresAt }` in localStorage is not
* proof of unlock — check the token:
*
* validateSession: async (session) => {
* if (!session.token) return false;
* const res = await fetch("/api/verify-access", {
* method: "POST",
* headers: { "Content-Type": "application/json" },
* body: JSON.stringify({ token: session.token }),
* });
* const body = await res.json();
* return body.ok === true;
* }
*
* Server mode hides the hash from the client bundle. It does not hide
* client-rendered children — fetch protected data only after unlock.
*/
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
const RATE_LIMIT_MAX_ATTEMPTS = 5; // adjust per deployment
const RATE_LIMIT_WINDOW_SECONDS = 60;
const TOKEN_TTL_MS = 5 * 60_000; // 5 minutes
// In-memory counter — acceptable ONLY for a single-instance/dev deployment.
// Vercel and most production hosts run multiple instances, so this counter
// would not be shared across them; swap in Redis or Vercel KV (a single
// shared store, same key scheme below) before deploying for real.
/** @type {Map<string, number>} */
const attemptsByKey = new Map();
/** @param {string} input */
function sha256Hex(input) {
return createHash("sha256").update(input, "utf8").digest("hex");
}
/** @param {string} actual @param {string | undefined} expected */
function hashesMatch(actual, expected) {
if (typeof expected !== "string") return false;
const a = Buffer.from(actual);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
/** @param {string} secret */
function signToken(secret) {
const payload = Buffer.from(JSON.stringify({ exp: Date.now() + TOKEN_TTL_MS })).toString("base64url");
const signature = createHmac("sha256", secret).update(payload).digest("base64url");
return `${payload}.${signature}`;
}
/** @param {string} token @param {string} secret */
function verifyToken(token, secret) {
const dot = token.lastIndexOf(".");
if (dot < 1) return false;
const payload = token.slice(0, dot);
const signature = token.slice(dot + 1);
const expected = createHmac("sha256", secret).update(payload).digest("base64url");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
if (!timingSafeEqual(a, b)) return false;
try {
const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
return typeof parsed.exp === "number" && parsed.exp > Date.now();
} catch {
return false;
}
}
/** @param {string} identifier */
function checkRateLimit(identifier) {
const windowStart = Math.floor(Date.now() / 1000 / RATE_LIMIT_WINDOW_SECONDS);
const key = `${identifier}:${windowStart}`;
const attempts = attemptsByKey.get(key) ?? 0;
if (attempts >= RATE_LIMIT_MAX_ATTEMPTS) return false;
attemptsByKey.set(key, attempts + 1);
return true;
}
/** @param {Request} request */
export async function POST(request) {
const identifier = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
if (!checkRateLimit(identifier)) {
return Response.json({ ok: false, reason: "network" }, { status: 429 });
}
/** @type {{ code?: unknown, token?: unknown }} */
let body;
try {
body = /** @type {{ code?: unknown, token?: unknown }} */ (await request.json());
} catch {
body = {};
}
const secret = process.env.KNOCK_CODES_TOKEN_SECRET;
if (typeof body.token === "string") {
const ok = typeof secret === "string" && verifyToken(body.token, secret);
return Response.json(ok ? { ok: true, token: body.token } : { ok: false, reason: "invalid" });
}
if (typeof body.code !== "string") {
return Response.json({ ok: false, reason: "network" }, { status: 500 });
}
if (!hashesMatch(sha256Hex(body.code), process.env.KNOCK_CODES_SERVER_HASH)) {
return Response.json({ ok: false, reason: "invalid" });
}
return Response.json({ ok: true, token: signToken(/** @type {string} */ (secret)) });
}Rate limiting and logging
Best practices for running server-mode verification endpoints in production.
- Rate-limit by a stable identifier (IP, or a header your edge/CDN sets) — every reference template below caps attempts per window and returns the same response shape for "rate-limited" and "errored" so neither is distinguishable to a client.
- Use a shared store for the counter the moment you run more than one instance — an in-memory Map (as in the Next.js/Express/Hono/Azure examples) only works for a single-instance deployment; swap in Redis, Vercel KV, or the equivalent before scaling out.
- Log failed attempts with a timestamp and identifier, never the submitted code or its hash — you want to notice a spike, not build a wordlist of your own visitors' guesses.
- Keep issued tokens short-lived and signed (the templates default to 5 minutes) — a long-lived token turns one successful guess into indefinite access.
- Rotate KNOCK_CODES_SERVER_HASH and KNOCK_CODES_TOKEN_SECRET the same way you'd rotate any other secret if either might have leaked.
Ready to set up server verification?
Follow the setup guide or pick a complete reference template.