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.
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.
mindmap
root((Cloudflare Access))
Identity Provider
One-time PIN
No OAuth app needed
Cloudflare emails the code
Works with any email address
GitHub OAuth
Requires an OAuth App
Good for org-based rules
Google
Cloud Console credentials
Verified domain optional
Application
Hostname and path scope
Full domain
Path prefix like /private/
Session duration
Cookie settings
Policy
Include rules
Email equals specific address
Email domain ends with
GitHub org membership
Everyone authenticated
Require rules
MFA
Device posture check
Exclude rules
Always blocked regardless
What stays the same
Hugo build process
S3 origin
SSL and CDN
No code changes
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:
kf7i.net/private//private/
mindmap
root((Request to /private/))
No valid session cookie
Redirect to Access login
Enter email address
Code emailed by Cloudflare
Enter code
Policy check
Email on allowlist
JWT cookie issued
Forwarded to /private/
Email not on allowlist
Access denied
No content served
Valid session cookie
Cloudflare validates JWT
Not expired
Request passes through to S3
Expired
Redirect to login
In the Cloudflare dashboard: Zero Trust → Access → Applications → Add an application → Self-hosted.
kf7i.net with path /private/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 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.
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.