Two locks on a static site
This site is static, but it has a private studio where I write and keep documents. It is guarded twice: once at the edge by an identity service, and again inside the code by checking the signed token myself. Why one lock is not enough, and the ten tests that prove the second one.
This site is a static site. Every public page is a file built ahead of time and served from a content network. There is no server to break into and no database behind the articles.
It also has a private side: a studio where I draft articles, publish them, and keep a few longer documents current with help from an AI harness. That part does need code and storage, so it runs as a few small serverless functions next to the static files. Exactly one person may use it: me.
The question was how to lock it without running an identity system of my own.
Figure 1. Lock 1 runs at the edge and asks for a Google sign-in limited to one email. Lock 2 runs inside every private function and checks the signed token again before anything is read or written.
Lock 1: at the edge
The site is hosted on Cloudflare Pages. Cloudflare Access sits in front of the private paths. When someone opens the studio, Access sends them to Google to sign in, and lets the request through only if the email matches the one address in its policy. It then attaches a signed token to every request.
That alone is a good lock. I still did not want it to be the only one.
Why one lock is not enough
The edge lock lives in a dashboard. The code lives in a repository. They change at different times, for different reasons, and nothing forces them to agree.
| Way the edge lock can fail | What happens with one lock |
|---|---|
| A policy is edited to add another email, for a test, and left that way | Someone else is in |
| A new private path is added in code but not to the protected paths | That path is public |
| The site is reached through a preview address the policy does not cover | The studio is public on that address |
| Access is switched off while debugging | Everything is public |
None of these is exotic. They are the ordinary mistakes of a person running a site in their spare time. A second lock in the code turns each of them from a breach into an error page.
Lock 2: in the function
A single middleware runs before every private function. It does not trust that Access ran. It verifies the token itself:
- Signature: the token must be signed with RS256 by a key published by my Access team. Keys are fetched from Access and cached for ten minutes.
- Issuer: it must come from my Access team, not any other.
- Audience: it must be for this exact application.
- Expiry: it must not have expired.
- Email: it must belong to the one owner address.
If the server is missing any of the settings needed for these checks, it refuses every request. A lock that opens when misconfigured is not a lock, so this one fails closed.
For local development there is a bypass, because there is no Access in front of my laptop. It works only when the request’s host is localhost. On any real address the bypass setting is ignored, even if someone sets it by mistake.
The ten tests
I tested the middleware with real signed tokens made from a test key, against every case I could think of.
| Case | Expected | Result |
|---|---|---|
| Valid token for the owner | Allowed | Allowed |
| No token | 401 | 401 |
| Valid token, different email | 403 | 403 |
| Wrong audience | 401 | 401 |
| Expired | 401 | 401 |
| Wrong issuer | 401 | 401 |
| Tampered payload | 401 | 401 |
| Token in the cookie, not the header | Allowed | Allowed |
| Server not configured | Refused | Refused |
| Bypass set, real host | Ignored | Ignored |
All ten pass. Against the running site, an unauthenticated call to the private API gets 401 and the studio page gets 403.
The smaller locks around it
- Same-origin writes only. Every change must come from the studio itself as JSON, which blocks forms on other sites from posting into it.
- No caching of any private response.
- Search engines told to stay out of private paths, and private pages marked not to be indexed.
- Private data never enters the build. It lives in a key-value store, not in the repository and not in the static files.
- Sharing is by expiring link. When I want someone to see a document, the studio makes a random, unguessable link with an expiry. Revoking it makes the link return 404 immediately.
What it cost
About a hundred lines of verification code, ten tests, and a free Cloudflare plan. No passwords stored anywhere, no user table, no session store. Google proves who I am, Access checks it at the edge, and my own code checks it again where the data is.
Two locks are not twice the work. They are the difference between trusting a setting and verifying it.