Putting a Password on a Static Site With One CloudFront Function
A static site has no server to check a password, which is fine right up until the day you need one. My staging site is the problem in miniature. It is a full mirror of production, except it also carr...
A static site has no server to check a password, which is fine right up until the day you need one.
My staging site is the problem in miniature. It is a full mirror of production, except it also carries every unpublished draft, so it must not be public. But it is the same kind of thing production is, a pile of files in an S3 bucket served through CloudFront, with no application server anywhere in the picture. There is no request handler to put a login in front of. The files just sit there and CloudFront hands them out.
So you have to check the password somewhere that does exist, and on a static site that place is the edge.
The right tool is smaller than you think
CloudFront has two ways to run your code on a request, and the difference matters a lot here.
A CloudFront Function is a tiny piece of JavaScript that runs at every edge location, on the request, before the cache. It is deliberately limited. It cannot make network calls or run for long. It exists to look at a request and make a fast yes-or-no decision, which is exactly the shape of checking a password. It runs in well under a millisecond and costs almost nothing.
Lambda@Edge is the heavier cousin. A real runtime, more time, the ability to talk to other services. It can do far more, and for gating a static site that is the problem, not the appeal. Reaching for Lambda@Edge to check one header is bringing a server to a job whose whole point was that there is no server.
The gate is a CloudFront Function. It reads the Authorization header, checks it against the expected credential for HTTP Basic Auth, and either returns a 401 that makes the browser ask for a password or lets the request through to the files. That is the entire job.
Keep the password out of the repository
The credential is not in the function. That matters, because the function is code and code goes in git, and a password in git is a password you have published whether you meant to or not.
So the password lives in a secret store on my machine, and it gets injected into the function only at deploy time. The version in the repository has a placeholder where the secret goes. The deploy fills it in, ships it, and the real value never touches version control. If you take one thing from this, take that one: the edge function and the secret it checks should never live in the same place.
The edge is unforgiving
Here is the part I learned the hard way. A mistake in a normal request handler breaks one request. A mistake at the edge breaks the site, because the edge is the thing every request passes through before anything else runs.
I had an older, abandoned attempt at this gate sitting in the repo, written as Lambda@Edge from before I understood that a Function was the right tool. The dead code looked enough like the live code to be dangerous, and one day it got deployed instead of the Function. Staging did not ask for a password. It returned 503 to everyone, a locked door with the lock jammed.
Two habits came out of that. The deploy now validates the function on a development stage and only publishes to live once it passes, because an auth function that fails does not fail open into an annoyance, it fails closed into an outage. And the dead Lambda@Edge file is now a loud tombstone that says, at the top, do not deploy this, here is the one that is real. Future-me does not get to rediscover the 503 by hand.
That is the whole lesson, and it generalizes past staging gates. A static site can do far more at the edge than its lack of a server suggests, but the edge punishes mistakes at the scale of the whole site rather than the single request, so you validate before you publish and you keep exactly one living version of anything that runs there. The rest of how I run this site lives at AI-assisted engineering.