Private Sections on a Hugo Static Site: A First-Principles Guide to WebAuthn

21 Feb 2026

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:

  1. Generate a fresh challenge for each authentication attempt
  2. Verify the signed assertion against stored credentials
  3. 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:

ConstraintWhy It Matters
Passkey-first sign-inNo password resets, no forgotten passwords
Tiny user lifecycleAdd/remove 1-2 accounts in minutes, not hours
Recovery pathLost device shouldn’t mean lost access
Minimal componentsFewer moving parts = fewer things to break
Route-level policyProtect /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:

Cons:

Best for: Admin areas, preview environments, private microsites where you want zero ops burden.

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:

ComponentRoleWhy This Choice
Pocket IDOIDC Identity ProviderPasskey-first design, single container, SQLite default
oauth2-proxyAuth gatewayStandard Nginx integration, handles OAuth flow
Nginx auth_requestAccess controlDelegates auth decisions to oauth2-proxy

Pros:

Cons:

Option 3: Full IAM Platform (Authentik/Keycloak)

How it works: Deploy a complete identity and access management platform with built-in proxy capabilities.

Pros:

Cons:

Best for: When you expect growth into broader IAM workflows or complex SSO policies.

For a 2-3 user deployment, this is the sweet spot:

Comparison with Alternatives

OptionSetup ComplexityMaintenanceResourcesPasskey Quality
Pocket IDLowLowLowHigh (passkey-first)
AuthentikMediumMediumMediumHigh
KanidmMediumMediumVery lowHigh
AutheliaMediumMediumVery lowMedium (caveats)
KeycloakHighHighMedium-highHigh

Why Pocket ID Wins

  1. Single container with SQLite default—no PostgreSQL required
  2. Passkey-first design—no password flows to configure around
  3. Minimal configuration—fewer knobs means fewer mistakes
  4. 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:

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:

Alternative: The Simpler Path

If the above feels like too much infrastructure, consider the pragmatic alternative:

Cloudflare Access + Email Allowlist

  1. Add your domain to Cloudflare
  2. Enable Cloudflare Access
  3. Create a policy: “Allow only these 3 email addresses”
  4. 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 SituationRecommended Approach
Want zero ops, trust CloudflareCloudflare Access
Self-hosted, minimal infrastructurePocket ID + oauth2-proxy
Expect growth into enterprise IAMAuthentik
Already on Cloudflare, want simplicityCloudflare Access
Want maximum control over auth flowsAuthentik or Keycloak

Summary

For a Hugo static site with a private section for 2-3 users:

  1. The fundamental problem is that WebAuthn needs a backend; Hugo has none
  2. The solution is to gate access at the proxy layer, not in Hugo
  3. The most ergonomic self-hosted stack is Pocket ID + oauth2-proxy + Nginx auth_request
  4. 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.


Sources