Blog
May 12, 2026-7 MIN READ
A Password Gate for a Static Site in One Edge Function

A Password Gate for a Static Site in One Edge Function

Sometimes you want a site on the internet but not of the internet — one password, no accounts, no database. An HMAC-signed cookie and about 150 lines of Netlify edge function does it, and it is worth knowing exactly what that buys you and what it doesn't.

By Baljeet Singh

I built a set of printable A4 forms for my household. Habit trackers, a weekly plan, a grocery sheet. Print, fill in by hand, throw away.

It's a static site. It has one user, or two if my wife counts, and there's nothing sensitive on it. But I didn't want it indexed, and I did not want a stranger stumbling onto my family's meal plan.

What I wanted was one password. Not accounts, not sign-up, not a database, not Auth0. Type a password once, get in for a month.

The whole gate is about 150 lines in one edge function, most of it the login page's markup. What follows is every part that carries weight — the config, the cookie, the verification and the two things I got wrong — rather than a listing you scroll past.

The Shape

A Netlify edge function on path: "/*" runs before every request:

import type { Config, Context } from "@netlify/edge-functions";

export const config: Config = {
  path: "/*",
};

It does three things:

  1. Cookie present and valid? Return nothing — the request passes through to the static site.
  2. POST /__login with the right password? Set a signed cookie, redirect.
  3. Anything else? Serve a login page.

No session store, because the cookie carries its own proof.

The cookie value is <expiry>.<signature>, where the signature is an HMAC of the expiry using a server-side secret:

async function hmac(secret: string, message: string): Promise<string> {
  const enc = new TextEncoder();
  const key = await crypto.subtle.importKey(
    "raw",
    enc.encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );
  const sig = await crypto.subtle.sign("HMAC", key, enc.encode(message));
  return btoa(String.fromCharCode(...new Uint8Array(sig)))
    .replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
}

That last line is base64url — strip the padding, swap +/ for -_. Cookies don't take raw base64 happily, and you'll save yourself an hour by doing it up front.

crypto.subtle is in the runtime already. No dependency, nothing to install, nothing to keep patched.

Verifying is the same operation backwards:

const [expStr, sig] = value.split(".");
const exp = parseInt(expStr, 10);
if (!Number.isFinite(exp) || Date.now() / 1000 > exp) return false;
const expected = await hmac(secret, expStr);
return timingSafeEqual(sig, expected);

Check expiry before computing the HMAC. An expired cookie is rejected on a cheap integer comparison rather than a signing operation.

That last line is not ===, and this is the one place the distinction earns its keep. The expiry in the cookie is attacker-chosen, so an attacker can ask the gate to check a signature over a message they picked. If the comparison returns early on the first wrong byte, the time it takes tells them how much of a signature they have right — and they can recover a valid one byte at a time without ever knowing the password. So compare every byte:

function timingSafeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return diff === 0;
}

Six lines, no dependency. crypto.subtle.verify does the same job if you would rather not hand-roll it — pass ["sign", "verify"] to importKey instead of ["sign"], or it throws when you try.

And the cookie flags matter more than the crypto:

Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=2592000

HttpOnly keeps JavaScript away from it, Secure keeps it off plain HTTP, SameSite=Lax handles the CSRF case for a form like this.

Two Small Things That Are Easy to Get Wrong

Fail closed on missing configuration.

if (!password || !secret) {
  return new Response("Auth not configured. Set AUTH_PASS and AUTH_SECRET.", {
    status: 503,
  });
}

Without this, a deploy that loses its environment variables serves the whole site publicly, and everything still looks fine. The failure has to be loud.

Sanitise the redirect target. The login form carries a next parameter so you land where you were going. That is an open redirect waiting to happen:

function safeNext(raw: string): string {
  // Strip first, then validate. The other order is the bug.
  const clean = raw.replace(/[\u0000-\u001F\u007F]/g, "");
  if (!clean.startsWith("/")) return "/";
  if (clean.startsWith("//") || clean.startsWith("/\\")) return "/";
  return clean;
}

Three things, and I got the first one wrong at first.

Strip before you validate, not after. If you check the string and then strip it, the strip can produce the thing you just rejected. next=/%0A/evil.com starts with / and its second character is a newline, not a slash — so it passes a startsWith("//") check, and then the strip turns it into //evil.com. The sanitiser manufactures the vulnerability. Order is the whole fix.

// is not the only protocol-relative spelling. //evil.com is the one people know about. /\evil.com is the one they miss: the URL parser normalises a backslash in the authority to a forward slash, so Chrome and Firefox both send you to evil.com. Reject both prefixes.

Strip every control character, not just \r\n. A tab gets the same bypass, and browsers strip it for you. Note that on Deno and Netlify the Headers object already throws on CR/LF in a value, so the strip is not what stands between you and header injection — it is there to stop a control character sneaking past the prefix checks.

The Login Page Is Inline HTML

The login page is a template literal inside the function. No build step, no framework, no separate route.

That sounds crude and it is exactly right: this page has to work when nothing else does. It is the only thing an unauthenticated visitor ever sees, so it must not depend on the site it is guarding. One <style> block, one form, Cache-Control: no-store, and <meta name="robots" content="noindex, nofollow">.

Styling it properly is worth ten minutes. It is the front door.

What This Does Not Do

The honest section, because a post that hands you an auth pattern owes you its limits.

The password comparison is not constant-time. The signature check is, for the reason above. submitted === password on the login path still is not, and I have left it that way: the attacker controls the guess but gets one answer per request over the network, which is the same channel the missing rate limiting already leaves wide open. Fix the rate limiting first; that is the bound that actually binds.

Rotating AUTH_PASS does not log anybody out. This is the one I had backwards, and it is worth being precise about. The cookie is <expiry>.<HMAC(AUTH_SECRET, expiry)>AUTH_PASS appears nowhere in it and is never consulted again after login. Changing it only changes what the form accepts from that point on. Every cookie already issued keeps working for the rest of its Max-Age, which here is thirty days. The revocation lever is AUTH_SECRET: rotate that and every existing cookie fails its signature check immediately. So if you share the password with someone and later want them out, rotate the secret, not the password — and accept that it logs everyone out, because there are no users to log out individually.

There is no rate limiting. Nothing stops someone trying passwords as fast as the edge will serve them. A long random password is doing all the work here — if the password is guessable, this is not protecting anything.

It is obscurity plus a password, not authorisation. Everyone who gets in sees everything. There are no roles, and there is nothing stopping a person who has the password from sharing it.

When to Use It

When the site genuinely has one audience and no secrets worth stealing. A household tool, a staging deploy, a draft nobody should stumble into, a tracker you want to open on your phone without it being public.

The moment you need per-person access, an audit trail, or revocation for one user, this is the wrong tool and you should reach for real auth.

But that moment arrives much later than people assume. I have reused this same function on every site since, changing only the cookie name and the colours of the login page.

© 2019-2026 Baljeet Singh. All rights reserved.