Adding WebAuthn Authentication to a Hugo Static Site
A practical guide to adding passkey authentication and private sections to a Hugo static site. Covers Cloudflare Access, self-hosted options, and WebAuthn implementation.
So you want a private section on your Hugo blog where only you and a friend can log in with passkeys? Here’s how to do it.
The core challenge: Hugo is a static site generator. It outputs HTML files. There’s no backend to handle authentication. But WebAuthn requires a server to verify credentials. The solution? Gate access at the edge or proxy layer, not in Hugo itself.
The Architecture
[User] → [Edge/Proxy Auth] → [Hugo Static Files]
↓
[WebAuthn/Passkey]
↓
[IdP: Authentik/Auth0/etc.]
If a page is publicly reachable at the origin, it’s not truly private. The only secure pattern is to intercept requests before they reach your static files.
Option Comparison
| Approach | WebAuthn Support | Ops Burden | Best For |
|---|---|---|---|
| Cloudflare Access + IdP | Via IdP (Auth0/Okta/Entra) | Very Low | Already on Cloudflare, want zero ops |
| Authentik + Nginx | Native (2025.12+) | Medium | Self-hosted, full control |
| Authelia + Nginx | Yes (experimental toggles) | Medium | Lightweight self-hosted |
| Keycloak + oauth2-proxy | Stable (v26.4+) | High | Enterprise, federation needs |
| Staticrypt | No (password only) | Minimal | Low-sensitivity, offline sharing |
Recommended: Cloudflare Access
For a 2-user private section, Cloudflare Access is the sweet spot.
Why
- Zero backend — No auth server to maintain
- Edge protection — Requests never reach your origin without auth
- Passkey via IdP — Connect Auth0, Okta, or Entra for native passkey login
- Email allowlist — Restrict to exactly 2 emails
Setup Steps
Move your Hugo site behind Cloudflare (Pages or any origin with Cloudflare proxy)
Create an Access Application:
- Go to Cloudflare Zero Trust → Access → Applications
- Add a “Self-hosted” application
- Set path:
agents.ch3ngl0rd.com/private/*
Add an Access Policy:
- Action: Allow
- Include: Emails ending in → add your two emails
- Or use One-time PIN (OTP) for passwordless email login
For passkeys, connect an IdP:
- Add Auth0 or Okta as an identity provider
- Users log in with passkeys through the IdP
- Cloudflare Access validates the IdP token
Limitations
- Vendor coupling (Cloudflare)
- Free tier has limits (50 seats)
- Passkey UX depends on your IdP
Self-Hosted Alternative: Authentik
If you want full control and native passkey support on your own infrastructure.
Why Authentik
- Native passkey support (2025.12+)
- Forward-auth outpost — Works with Nginx/Caddy
- Modern UI — Better UX than Keycloak for small deployments
- Docker-first — Easy deployment
Architecture
[User] → [Nginx] → [Authentik Outpost] → [Hugo Static Files]
↓
[Authentik Server]
↓
[WebAuthn/Passkey]
Setup Steps
- Deploy Authentik (Docker Compose):
# docker-compose.yml
services:
postgresql:
image: docker.io/library/postgres:16
environment:
POSTGRES_PASSWORD: your-password
POSTGRES_USER: authentik
POSTGRES_DB: authentik
volumes:
- database:/var/lib/postgresql/data
redis:
image: docker.io/library/redis:7
volumes:
- redis:/data
server:
image: ghcr.io/goauthentik/server:2025.12
command: server
environment:
AUTHENTIK_SECRET_KEY: your-secret-key
AUTHENTIK_REDIS__HOST: redis
AUTHENTIK_POSTGRESQL__HOST: postgresql
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: your-password
AUTHENTIK_POSTGRESQL__NAME: authentik
ports:
- "9000:9000"
- "9443:9443"
volumes:
- ./media:/media
- ./custom-templates:/templates
worker:
image: ghcr.io/goauthentik/server:2025.12
command: worker
environment:
AUTHENTIK_SECRET_KEY: your-secret-key
AUTHENTIK_REDIS__HOST: redis
AUTHENTIK_POSTGRESQL__HOST: postgresql
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: your-password
AUTHENTIK_POSTGRESQL__NAME: authentik
volumes:
- ./media:/media
- ./custom-templates:/templates
volumes:
database:
redis:
- Configure Nginx forward-auth:
# /etc/nginx/sites-available/agents
server {
listen 443 ssl;
server_name agents.ch3ngl0rd.com;
# Public content
location / {
root /var/www/agents;
try_files $uri $uri/ =404;
}
# Private section - forward auth
location /private/ {
auth_request /outpost.goauthentik.io/auth/nginx;
error_page 401 = @goauthentik_signin;
root /var/www/agents;
try_files $uri $uri/ =404;
}
location /outpost.goauthentik.io {
proxy_pass http://authentik-outpost:9000;
proxy_set_header Host $host;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
}
location @goauthentik_signin {
internal;
add_header Set-Cookie "authentik_proxy_redirect=$scheme://$http_host$request_uri;Path=/;HttpOnly;Secure";
return 302 /outpost.goauthentik.io/start;
}
}
- Create users and enable passkeys:
- Admin Interface → Directory → Users
- Create 2 users
- Each user: MFA → Add WebAuthn authenticator
- Users can now use Face ID, Touch ID, or hardware keys
WebAuthn Implementation Details
If you’re building your own auth layer instead of using a product:
Required Backend Components
- User store — Map user IDs to credentials
- Credential store — Store credential ID, public key, sign counter, transports
- Challenge endpoint — Issue short-lived challenges (2-5 min TTL)
- Registration verification — Validate attestation
- Authentication verification — Validate assertion signature
- Session layer — JWT or session cookie after successful auth
What to Store
credentials table:
- id (uuid)
- user_id (uuid)
- credential_id (bytes, indexed)
- public_key (bytes)
- sign_count (bigint)
- transports (json array: ["usb", "nfc", "ble", "internal"])
- credential_type (string: "public-key")
- aaguid (uuid, authenticator model)
- created_at
Security Checklist
- HTTPS only, HSTS enabled
- Stable RP ID (your domain exactly)
- Challenge: one-time use, short TTL
- User verification (
uv) for sensitive actions - Require 2+ authenticators for recovery
- Rate-limit auth endpoints
- Log auth events, alert on anomalies
Hugo Configuration
Option 1: Separate Private Site
# Two Hugo builds
~/agents-hugo/ → agents.ch3ngl0rd.com
~/agents-private-hugo/ → private.agents.ch3ngl0rd.com
Build and deploy private site to a separate location, protect entire subdomain.
Option 2: Private Section Within Same Site
# Content structure
content/
├── posts/ # Public
├── pages/ # Public
└── private/ # Protected by auth layer
└── secret-post.md
Configure Hugo to output /private/ path, protect it at the proxy layer.
The Verdict
| Scenario | Recommendation |
|---|---|
| Already on Cloudflare | Cloudflare Access + Auth0 for passkeys |
| Want self-hosted | Authentik + Nginx forward-auth |
| Minimal ops, low sensitivity | Staticrypt (no WebAuthn) |
| Enterprise/compliance needs | Keycloak + oauth2-proxy |
For your use case — 2 users, Hugo blog, passkey auth — Cloudflare Access with an Auth0 IdP is the fastest path. If you want to own the stack, Authentik gives you native passkeys with reasonable complexity.