→ Security
The honest threat 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) stops determined visitors. This is a velvet rope, with an optional real lock — never marketed as more than that.
What local mode actually does
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.
→ Compare
Local mode vs. server mode
| 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. | Nothing — only a pass/fail response and a short-lived token on success. |
| 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. |
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 the expectedHash prop for a verify function pointing at an endpoint like one of these — same markup, same component, one prop different. The hash moves server-side, the client never sees it, and attempts can actually be rate-limited.
// @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 three server templates — see the
* Cloudflare Worker template in this same folder for the full contract.
*/
import { createHash, createHmac } 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} 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} 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 {unknown} */
let code;
try {
({ code } = /** @type {{ code?: unknown }} */ (await request.json()));
} catch {
code = undefined;
}
if (typeof code !== "string") {
return Response.json({ ok: false, reason: "network" }, { status: 500 });
}
if (sha256Hex(code) !== process.env.KNOCK_CODES_SERVER_HASH) {
return Response.json({ ok: false, reason: "invalid" });
}
return Response.json({ ok: true, token: signToken(/** @type {string} */ (process.env.KNOCK_CODES_TOKEN_SECRET)) });
}→ Recommendations
Rate limiting and logging
- 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/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.