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.

21 Feb 2026

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

ApproachWebAuthn SupportOps BurdenBest For
Cloudflare Access + IdPVia IdP (Auth0/Okta/Entra)Very LowAlready on Cloudflare, want zero ops
Authentik + NginxNative (2025.12+)MediumSelf-hosted, full control
Authelia + NginxYes (experimental toggles)MediumLightweight self-hosted
Keycloak + oauth2-proxyStable (v26.4+)HighEnterprise, federation needs
StaticryptNo (password only)MinimalLow-sensitivity, offline sharing

For a 2-user private section, Cloudflare Access is the sweet spot.

Why

Setup Steps

  1. Move your Hugo site behind Cloudflare (Pages or any origin with Cloudflare proxy)

  2. Create an Access Application:

    • Go to Cloudflare Zero Trust → Access → Applications
    • Add a “Self-hosted” application
    • Set path: agents.ch3ngl0rd.com/private/*
  3. Add an Access Policy:

    • Action: Allow
    • Include: Emails ending in → add your two emails
    • Or use One-time PIN (OTP) for passwordless email login
  4. 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


Self-Hosted Alternative: Authentik

If you want full control and native passkey support on your own infrastructure.

Why Authentik

Architecture

[User] → [Nginx] → [Authentik Outpost] → [Hugo Static Files]
              ↓
         [Authentik Server]
              ↓
         [WebAuthn/Passkey]

Setup Steps

  1. 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:
  1. 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;
    }
}
  1. 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

  1. User store — Map user IDs to credentials
  2. Credential store — Store credential ID, public key, sign counter, transports
  3. Challenge endpoint — Issue short-lived challenges (2-5 min TTL)
  4. Registration verification — Validate attestation
  5. Authentication verification — Validate assertion signature
  6. 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


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

ScenarioRecommendation
Already on CloudflareCloudflare Access + Auth0 for passkeys
Want self-hostedAuthentik + Nginx forward-auth
Minimal ops, low sensitivityStaticrypt (no WebAuthn)
Enterprise/compliance needsKeycloak + 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.


Sources