Private Sections on a Hugo Static Site: A First-Principles Guide to WebAuthn
You have a Hugo static site. You want a private section—something only you and a couple friends can access. You want WebAuthn/passkeys because passwords are obsolete.
Here’s the problem: Hugo is static. WebAuthn is not.
This guide works through the problem from first principles and delivers the most ergonomic solution for a 2-3 user deployment.
The Fundamental Problem
WebAuthn requires a server-side ceremony:
- Generate a fresh challenge for each authentication attempt
- Verify the signed assertion against stored credentials
- Create and enforce a session for subsequent requests
None of this exists in a static site. Hugo generates HTML files at build time. There’s no runtime, no database, no session state.
So the real problem becomes: How do you put a stateful auth gate in front of static files while keeping it manageable for 2-3 humans?
What “Ergonomic” Means for 2-3 Users
Before evaluating solutions, let’s define the constraints:
| Constraint | Why It Matters |
|---|---|
| Passkey-first sign-in | No password resets, no forgotten passwords |
| Tiny user lifecycle | Add/remove 1-2 accounts in minutes, not hours |
| Recovery path | Lost device shouldn’t mean lost access |
| Minimal components | Fewer moving parts = fewer things to break |
| Route-level policy | Protect /private/ only, not the whole site |
Architectural Options
Option 1: Managed Edge Access (Cloudflare Access)
How it works: Protect routes at the edge using Cloudflare’s Zero Trust platform. Users authenticate via an IdP (Google, GitHub, Okta) that supports passkeys.
User → Cloudflare Edge → [Auth Check] → Your Origin
↓
IdP Login (passkeys via Google/GitHub/etc.)
Pros:
- Lowest operational overhead
- Fast setup (minutes, not hours)
- Built-in passkey support via major IdPs
- No backend to maintain
Cons:
- External dependency (Cloudflare + IdP)
- Less control over auth flows
- Policy model tied to vendor
Best for: Admin areas, preview environments, private microsites where you want zero ops burden.
Option 2: Self-Hosted IdP + Auth Proxy (Recommended)
How it works: Run a lightweight OIDC provider with native passkey support, fronted by an auth proxy that integrates with Nginx.
User → Nginx → [auth_request] → oauth2-proxy → Pocket ID
↓
Passkey Authentication
↓
Session Cookie Issued
↓
Static Files Served
Why this combination:
| Component | Role | Why This Choice |
|---|---|---|
| Pocket ID | OIDC Identity Provider | Passkey-first design, single container, SQLite default |
| oauth2-proxy | Auth gateway | Standard Nginx integration, handles OAuth flow |
| Nginx auth_request | Access control | Delegates auth decisions to oauth2-proxy |
Pros:
- Self-hosted and private
- Passkey-native UX (Pocket ID is explicitly passkey-focused)
- Much lighter than Keycloak/Authentik
- Works cleanly with existing Nginx + Hugo setup
Cons:
- 2-3 services to run
- Session/cookie tuning required
- You own the infrastructure
Option 3: Full IAM Platform (Authentik/Keycloak)
How it works: Deploy a complete identity and access management platform with built-in proxy capabilities.
Pros:
- Rich policies and flows
- Future scalability
- Enterprise features (SSO, provisioning, branding)
Cons:
- Operational weight (PostgreSQL, Redis, workers)
- Overkill for 2-3 users
- Upgrade discipline required
Best for: When you expect growth into broader IAM workflows or complex SSO policies.
The Recommended Stack: Pocket ID + oauth2-proxy + Nginx
For a 2-3 user deployment, this is the sweet spot:
Comparison with Alternatives
| Option | Setup Complexity | Maintenance | Resources | Passkey Quality |
|---|---|---|---|---|
| Pocket ID | Low | Low | Low | High (passkey-first) |
| Authentik | Medium | Medium | Medium | High |
| Kanidm | Medium | Medium | Very low | High |
| Authelia | Medium | Medium | Very low | Medium (caveats) |
| Keycloak | High | High | Medium-high | High |
Why Pocket ID Wins
- Single container with SQLite default—no PostgreSQL required
- Passkey-first design—no password flows to configure around
- Minimal configuration—fewer knobs means fewer mistakes
- OIDC compliant—works with standard oauth2-proxy
Implementation Blueprint
Step 1: Deploy Pocket ID
# docker-compose.yml
services:
pocket-id:
image: ghcr.io/pocket-id/pocket-id:latest
ports:
- "3001:3000"
environment:
- PUBLIC_APP_URL=https://id.yourdomain.com
- TRUST_PROXY=true
volumes:
- pocket-id-data:/app/data
restart: unless-stopped
volumes:
pocket-id-data:
Key configuration:
- Set up
id.yourdomain.comas a subdomain - Enable HTTPS (required for WebAuthn)
- Create 2-3 user accounts
- Each user registers their passkey on first login
Step 2: Deploy oauth2-proxy
# Add to docker-compose.yml
services:
oauth2-proxy:
image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0
ports:
- "4180:4180"
environment:
- OAUTH2_PROXY_PROVIDER=oidc
- OAUTH2_PROXY_CLIENT_ID=<from-pocket-id>
- OAUTH2_PROXY_CLIENT_SECRET=<from-pocket-id>
- OAUTH2_PROXY_OIDC_ISSUER_URL=https://id.yourdomain.com
- OAUTH2_PROXY_REDIRECT_URL=https://yourdomain.com/oauth2/callback
- OAUTH2_PROXY_COOKIE_SECRET=<generate-32-char-secret>
- OAUTH2_PROXY_EMAIL_DOMAINS=*
- OAUTH2_PROXY_REVERSE_PROXY=true
- OAUTH2_PROXY_SKIP_PROVIDER_BUTTON=true
restart: unless-stopped
Step 3: Configure Nginx
# Protect /private/ routes
location /private/ {
auth_request /oauth2/auth;
error_page 401 = /oauth2/sign_in;
# Pass user identity to backend (optional)
auth_request_set $user $upstream_http_x_auth_request_user;
proxy_set_header X-User $user;
try_files $uri $uri/ =404;
}
# OAuth2 proxy endpoints
location /oauth2/ {
proxy_pass http://127.0.0.1:4180;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Auth-Request-Redirect $request_uri;
}
# Internal auth check endpoint
location = /oauth2/auth {
internal;
proxy_pass http://127.0.0.1:4180/oauth2/auth;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Real-IP $remote_addr;
}
Step 4: Create Hugo Content
Structure your private content:
content/
├── private/
│ ├── _index.md # Private section index
│ ├── secret-post.md # Private content
│ └── ...
└── posts/
└── ... # Public content
Configure Hugo to render the private section:
# config.yaml
outputs:
section:
- HTML
- RSS
Common Pitfalls
1. RP ID / Origin Mismatches
WebAuthn is bound to a Relying Party (RP) ID. If your site is yourdomain.com but Pocket ID is at id.yourdomain.com, ensure the RP ID is configured correctly.
Fix: Set PUBLIC_APP_URL in Pocket ID to match the domain users will authenticate from.
2. Missing OAuth State Validation
The state parameter prevents CSRF attacks. Never skip this.
Fix: oauth2-proxy handles this automatically—don’t disable it.
3. Large Cookies Breaking Nginx
OAuth tokens can create large cookies that exceed Nginx’s default header buffer.
Fix: Increase buffer sizes:
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
4. Protecting Only One Path
If you protect /private/ but leave /private/file.pdf accessible via direct URL, you’ve accomplished nothing.
Fix: Use location /private (without trailing slash) to protect all subpaths.
5. No Recovery Path for Lost Passkeys
If a user loses their device and has no backup, they’re locked out.
Fix:
- Encourage users to register multiple passkeys (phone + security key)
- Keep an admin recovery mechanism (Pocket ID supports this)
Alternative: The Simpler Path
If the above feels like too much infrastructure, consider the pragmatic alternative:
Cloudflare Access + Email Allowlist
- Add your domain to Cloudflare
- Enable Cloudflare Access
- Create a policy: “Allow only these 3 email addresses”
- Users authenticate via Google/GitHub/Microsoft (all support passkeys)
This gives you passkey auth with zero infrastructure. The tradeoff: you’re trusting Cloudflare and an external IdP.
Decision Matrix
| Your Situation | Recommended Approach |
|---|---|
| Want zero ops, trust Cloudflare | Cloudflare Access |
| Self-hosted, minimal infrastructure | Pocket ID + oauth2-proxy |
| Expect growth into enterprise IAM | Authentik |
| Already on Cloudflare, want simplicity | Cloudflare Access |
| Want maximum control over auth flows | Authentik or Keycloak |
Summary
For a Hugo static site with a private section for 2-3 users:
- The fundamental problem is that WebAuthn needs a backend; Hugo has none
- The solution is to gate access at the proxy layer, not in Hugo
- The most ergonomic self-hosted stack is Pocket ID + oauth2-proxy + Nginx auth_request
- The simplest alternative is Cloudflare Access if you’re willing to trust external providers
The key insight: don’t try to make Hugo dynamic. Keep Hugo static, and put the auth gate in front of it.