RunBSD

Magic Link Authentication on OpenBSD

10 June 2026

Goal: a second private section of runbsd.me for less technically-inclined visitors — no client certificates, no passwords. The user enters their email address, receives a login link, clicks it, and lands in /private/ with a session cookie good for 7 days. The link is single-use and expires in 15 minutes.

The service is written in Go, cross-compiled on macOS for OpenBSD amd64, and runs as a FastCGI daemon behind OpenBSD httpd. It uses pledge(2) and unveil(2) for process hardening, and sends email via the Resend API.

Architecture

The flow has four steps:

  1. User visits /login, enters their email, and submits the form.
  2. The server generates a 32-byte cryptographically random token, stores it in memory with a 15-minute TTL, and emails a link to /auth/verify?token=… via Resend.
  3. The user clicks the link. A GET to /auth/verify shows a confirmation page (safe for email prefetchers that follow links). A POST to the same URL consumes the token and creates a session.
  4. The server sets a session cookie and redirects to /private/. The cookie is HttpOnly, Secure, SameSite=Lax, and expires after 7 days.

An email allow-list in the environment variable ALLOWED_EMAILS limits who can receive a link. The login page always shows the same "check your email" response whether the address is on the list or not, to avoid leaking which addresses are registered.

Project layout

runbsd.me/magiclinks/
├── main.go                        # entry point, HTTP handlers, flag parsing
├── go.mod
├── internal/
│   ├── tokenstore/tokenstore.go   # token generation, storage, expiry
│   ├── session/session.go         # session creation, cookie management
│   ├── mailer/mailer.go           # Resend API client
│   ├── ui/ui.go                   # HTML template rendering
│   │   └── templates/             # base.html + per-page templates
│   └── openbsd/
│       ├── pledge.go              # pledge(2) + unveil(2) (openbsd build tag)
│       └── pledge_other.go        # no-op stub for other platforms
└── deploy/
    ├── magiclinks.rc              # rc.d service script
    └── zimaboard/
        ├── httpd.conf             # httpd config for local dev
        └── DEPLOY_ZIMABOARD.md

Key implementation details

Token store

Tokens are 32 bytes from crypto/rand, hex-encoded to 64 characters. The store is a mutex-protected in-memory map. The interface has three methods: Create, Peek (validates without consuming — used by the GET handler so prefetchers don't burn the token), and Consume (validates and deletes — used by the POST handler). A background goroutine sweeps expired tokens every minute.

Prefetch-safe confirm page

Many email clients and security tools follow links in emails automatically. A naive implementation that validates the token on GET would burn single-use tokens before the user even sees them. The fix: GET /auth/verify calls Peek (read-only validation) and shows a page with a "Sign in" button. Only the POST — triggered by the user clicking that button — calls Consume and creates a session.

FastCGI and the httpd chroot

OpenBSD httpd is chrooted to /var/www. The FastCGI Unix socket lives at /var/www/run/magiclinks.sock so httpd can reach it as /run/magiclinks.sock from inside the chroot. The service user _magiclinks is added to the www group and the wrapper script sets umask 0007 so the socket is created srwxrwx--- _magiclinks:www — accessible to httpd's www user without being world-readable.

pledge(2) and unveil(2)

After startup, the process calls unveil(2) to restrict filesystem visibility, then pledge(2) to restrict syscalls. The promises and paths required were worked out through testing — several were non-obvious:

Final pledge promises: stdio unix inet dns rpath prot_exec
Final unveil paths: /etc/ssl (r), /etc/resolv.conf (r), /etc/hosts (r), /var/www/run (rwc)

rc.d service

The rc.d script required a few non-obvious settings:

Environment variables are loaded via a wrapper script /usr/local/bin/magiclinks-start that sources /etc/magiclinks.env with set -a before exec'ing the binary. The env file is root:_magiclinks 640 so only the service user can read it.

On your Mac — build and copy

cd "Magic Links"
GOOS=openbsd GOARCH=amd64 go build -ldflags="-s -w" -o magiclinks .
scp magiclinks your-user@YOUR_SERVER:/tmp/
scp deploy/magiclinks.rc your-user@YOUR_SERVER:/tmp/

On the server — initial setup (as root)

Create the service user

useradd -d /var/empty -s /sbin/nologin -L daemon _magiclinks
usermod -G www _magiclinks

Install the binary

install -o root -g bin -m 555 /tmp/magiclinks /usr/local/bin/magiclinks

Create the wrapper script

cat > /usr/local/bin/magiclinks-start << 'EOF'
#!/bin/ksh
set -a
. /etc/magiclinks.env
set +a
umask 0007
exec /usr/local/bin/magiclinks -socket /var/www/run/magiclinks.sock
EOF
chmod 555 /usr/local/bin/magiclinks-start

Create the secrets file

install -o root -g _magiclinks -m 640 /dev/null /etc/magiclinks.env

Edit /etc/magiclinks.env:

RESEND_API_KEY=re_your_key_here
ALLOWED_EMAILS=you@example.com
MAGICLINKS_BASE_URL=https://yourdomain.com

Install and enable the rc.d script

install -o root -g wheel -m 755 /tmp/magiclinks.rc /etc/rc.d/magiclinks
rcctl enable magiclinks
rcctl set magiclinks user _magiclinks

Add location blocks to httpd.conf

location "/login"       { fastcgi socket "/run/magiclinks.sock" }
location "/auth/verify" { fastcgi socket "/run/magiclinks.sock" }
location "/private/*"   { fastcgi socket "/run/magiclinks.sock" }
location "/logout"      { fastcgi socket "/run/magiclinks.sock" }

Start everything

httpd -n
rcctl restart httpd
rcctl start magiclinks
rcctl check magiclinks

Updating the binary

# On your Mac:
GOOS=openbsd GOARCH=amd64 go build -ldflags="-s -w" -o magiclinks .
scp magiclinks your-user@YOUR_SERVER:/tmp/

# On the server (as root):
rcctl stop magiclinks
install -o root -g bin -m 555 /tmp/magiclinks /usr/local/bin/magiclinks
rcctl start magiclinks
rcctl check magiclinks

Troubleshooting

httpd returns 500 — use httpd -dvv

Run rcctl stop httpd && httpd -dvv, make a request, and watch the output. It prints the exact per-connection error ("Permission denied", etc.) that the normal log omits.

Permission denied on the socket

Verify _magiclinks is in the www group and the wrapper sets umask 0007. The socket should show srwxrwx--- _magiclinks www:

ls -la /var/www/run/magiclinks.sock

Run the binary manually to see errors

rcctl stop magiclinks
rm -f /var/www/run/magiclinks.sock
env $(cat /etc/magiclinks.env | xargs) /usr/local/bin/magiclinks -socket /var/www/run/magiclinks.sock

Notes