Locking Down /private on a Static Site with Cloudflare Access

Jun 22, 2026

My site is a pile of HTML files on S3. There’s no login form, no session cookie, no database — just a CDN edge pushing pre-built pages. That’s the deal with static sites: fast and simple, right up until you want to lock something down.

I wanted a /private/ section — a place to put notes and content for a small group without making it world-readable. The constraint is that Hugo doesn’t know who’s asking, and neither does S3. Enforcement has to happen somewhere between the visitor and the origin, which for a site already behind Cloudflare means one obvious answer: Cloudflare Access.

What Cloudflare Access actually does

Access is part of Cloudflare’s Zero Trust product. The free tier covers up to 50 authenticated users and costs nothing — no credit card, no trial. It intercepts requests at the edge, checks for a valid session, and either passes the request through or redirects to a login flow. Your origin never sees unauthenticated requests at all.

The setup has three pieces: an identity provider, an application definition, and a policy. That’s it.

Why one-time PIN is the right call here

For a small allowlist of known people, one-time PIN is the simplest possible identity provider. There’s no OAuth app to register, no client credentials to manage, no Google Cloud Console to navigate. Cloudflare emails a short code to whoever’s trying to log in. If their email isn’t on your allowlist, the policy blocks them even if they receive a code — authentication and authorization are separate steps.

The flow from a visitor’s perspective:

  1. They hit kf7i.net/private/
  2. Cloudflare redirects them to the Access login page
  3. They enter their email address
  4. Cloudflare sends a one-time code to that address
  5. They enter the code
  6. Access checks the policy — is this email on the allowlist?
  7. If yes, a signed JWT cookie is set and they’re forwarded to /private/
  8. Subsequent requests carry the cookie; no re-login until the session expires

Setting it up

In the Cloudflare dashboard: Zero Trust → Access → Applications → Add an application → Self-hosted.

Then add a policy. Name it something like “Email allowlist” and set the action to Allow. Under Include, add one rule per person:

Rule type: Email
Value: [email protected]
Rule type: Email
Value: [email protected]

Add as many as you need. The Include rules are evaluated with OR logic — any match passes. There’s no minimum, so a single email is a valid allowlist of one.

For the identity provider, go to Settings → Authentication, add One-time PIN, and enable it on the application. No credentials required — it just works.

The Hugo side

The content itself lives at content/private/ and Hugo builds it to public/private/ as normal. The gating is entirely at the edge — Hugo doesn’t know the path is protected and doesn’t need to.

One thing worth handling: Hugo includes every page in the sitemap by default. Access blocks the actual content, so a sitemap URL isn’t a security hole, but it’s untidy. Fix it with a cascade in the section’s _index.md:

---
title: Private
cascade:
  sitemap:
    disable: true
---

The cascade key pushes that front matter down to every page in the section, so you don’t have to set it on each post individually. The whole section disappears from sitemap.xml without touching any individual files.

Same idea applies to RSS if you have a site-wide feed — you can exclude the section from the feed output in _index.md with outputs: [] or by limiting outputs to just HTML.

What this doesn’t do

Access is an authentication and authorization layer, not content encryption. The files on S3 are publicly readable if someone has direct S3 access or if a URL leaks. For a personal site with a handful of trusted people, that’s an acceptable tradeoff. If the content genuinely needs to be secret, you’d want S3 bucket policies restricted to Cloudflare IPs and a signed URL workflow — considerably more involved.

For what I’m actually putting behind /private/ — notes, drafts, content meant for a small audience — the Access layer is the right amount of protection without overcomplicating a site that’s supposed to be a stack of flat files.


The whole thing — Access application, policy, identity provider — takes about ten minutes to configure. The Hugo side is two lines of front matter. Nothing about the build pipeline, the CI, or the deployment changes. That ratio of effort to outcome is hard to argue with.