I wanted one private place for my dashboards: finance, notes, project status, whatever comes next. Reachable from any browser, no VPN, and no server for me to patch. The obvious way to host something like that cheaply is the same S3 and CloudFront setup this blog uses. The obvious problem is that static hosting has no concept of a user. Every object in the bucket is either public or it isn’t.

The usual fixes all add something to run or pay for: a VPN, a load balancer with built-in authentication, a full application server. I went smaller. A CloudFront Function checks a signed cookie on every single request, and a tiny Lambda handles the actual sign-in. This post is about the gate. The next one is about the sign-in.

The design splits authentication into two jobs that never talk to each other directly:

  • The gate is a CloudFront Function on the viewer-request event. It runs on every request and answers one question: does this request carry a valid session cookie?
  • The sign-in is a Lambda behind the /auth/* path. It talks to the identity provider and, if you check out, mints the cookie.

The only thing they share is the cookie format and a secret. The gate never calls the identity provider, never parses a JWT, and never touches a database. That keeps the per-request work to one HMAC, which matters when the runtime gives you about a millisecond of compute and a 10 KB limit on source code.

Why viewer-request, specifically

CloudFront runs viewer-request functions before it checks its cache. That ordering is the whole security model. If the check ran later, at the origin, then the first authorized visitor would warm the cache and every visitor after them would be served the cached copy without ever being checked. A private dashboard that’s private only on a cache miss is not private.

Because the gate runs before the cache, cached objects are just as protected as uncached ones. The same function is attached to the default behavior and to the /data/* behavior that serves the dashboard JSON, so there’s no path through the distribution that skips it, other than /auth/*, which is the sign-in itself.

The session cookie is deliberately boring:

hub_session=<expiry>.<hmac-sha256(secret, "v1:" + expiry)>

There’s no user data in it at all. The expiry is a Unix timestamp, and it sits inside the signed value, so you can’t extend your own session by editing the number. The signature can only be produced by something holding the secret. That’s a message authentication code doing exactly what it’s for: proving integrity and origin, not hiding anything.

The v1: prefix is a small habit that pays off. The sign-in flow signs a different, short-lived cookie with the same secret, under a login: prefix. Because each purpose signs a different string, a value minted for one can never validate as the other. It’s domain separation in one line.

The cookie is set with Secure, HttpOnly, and SameSite=Lax. Secure keeps it off plain HTTP. HttpOnly means page JavaScript can’t read it, so a script injection bug can’t simply exfiltrate the session. SameSite=Lax keeps the browser from attaching it to cross-site subrequests and form posts, which takes most cross-site request forgery off the table while still allowing a normal link click to arrive signed in.

Checking it without leaking timing

Here’s the validation:

function validSession(cookies, secret, now) {
  const c = cookies[COOKIE];
  if (!c || !c.value) return false;
  const dot = c.value.indexOf('.');
  if (dot < 1) return false;
  const exp = parseInt(c.value.substring(0, dot), 10);
  if (!(exp > now)) return false;
  return safeEqual(c.value.substring(dot + 1), sign(secret, exp), secret);
}

// Compare HMACs of both sides so a mismatch position reveals nothing about the real value.
function safeEqual(a, b, key) {
  const ha = crypto.createHmac('sha256', key).update(String(a)).digest('hex');
  const hb = crypto.createHmac('sha256', key).update(String(b)).digest('hex');
  return ha === hb;
}

A naive === between the presented signature and the expected one returns as soon as the first character differs. In principle, an attacker who can measure that difference can recover a valid signature one character at a time. The fix is a constant-time comparison, and the CloudFront Functions runtime doesn’t offer one: its crypto module has hashes and HMAC, and that’s about it.

The workaround is a classic. HMAC both values again with the same key and compare the results. The comparison can still exit early, but now it leaks information about two digests the attacker can’t predict, not about the real signature. It costs two extra HMACs per request, which is nothing.

Failing closed

The secret lives in a CloudFront KeyValueStore associated with the function. If reading it fails, the function returns a 503 with no-store. It does not let the request through. Every authentication check should have an answer to “what happens when I can’t check?” and the answer should be “no.”

The same default-deny thinking shapes the routing. A short, exact allowlist (the login page, the favicon, robots.txt, and a /public/ prefix for the error page) is reachable anonymously. Everything else requires a session, including paths that don’t exist yet. A new dashboard is private the moment it’s uploaded, without anyone remembering to protect it.

Anonymous requests get different treatment depending on what’s asking. A browser loading a page gets a 302 to the login page with a next parameter. A fetch() for JSON gets a 401 with a small JSON body, so the dashboard script can bounce to the login page itself instead of trying to parse an HTML redirect as data. Both responses carry cache-control: no-store, so a redirect meant for one anonymous visitor never gets cached and served to someone else.

Closing the side door

A gate in front of a bucket is decoration if the bucket can be read directly. The origin here is a private S3 bucket behind Origin Access Control. CloudFront signs every origin request with SigV4, and the bucket policy only accepts reads from that one distribution. There’s no website endpoint, no public read, and no second way in.

The secret itself is written by a small script, straight into the KeyValueStore and into an SSM SecureString parameter for the Lambda. Terraform creates the store and outputs its ARN, but it never sees the value, so the secret isn’t sitting in a state file somewhere. Running the script again rotates the secret, which signs out every browser at once. That’s the response to a lost laptop.

What it doesn’t do

The trade-offs are real, and I’d rather name them:

  • Sessions are fixed at 30 days. A viewer-request function can modify the request on its way in, but it can’t add a Set-Cookie to a response it passes through, so there’s no sliding renewal. You sign in again once a month per device.
  • Revocation is all or nothing. There’s no per-session list to check, by design, so rotating the secret is the only revocation. For one user, that’s fine. For a team, you’d want a session ID and a denylist, and probably a different architecture.
  • It’s a gate, not an authorization system. Everyone with a valid cookie sees everything. That’s the correct model for a personal site and the wrong one for almost anything shared.

For testing, the repo runs the real function through a local shim that mimics the runtime’s limits: no classes, no destructuring, the 10 KB ceiling. That catches most mistakes before deploy, and aws cloudfront test-function against the deployed version is the final word.

The whole gate is about 4 KB. It costs ten cents per million requests, and it has no server to forget about. The part that decides who gets a cookie in the first place is next.