Magic Link Authentication on OpenBSD
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:
- User visits
/login, enters their email, and submits the form. - 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. - The user clicks the link. A GET to
/auth/verifyshows a confirmation page (safe for email prefetchers that follow links). A POST to the same URL consumes the token and creates a session. - 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:
prot_exec— required by Go's runtime, which usesmmap(MAP_EXEC)for internal memory management even in a statically compiled binary.unix— required for the FastCGI Unix domain socket./etc/resolv.confand/etc/hosts— must be unveiled for DNS resolution to work; thednspledge promise alone is not sufficient.
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:
rc_bg=YES— without this, rc.subr blocks waiting forrc_start()to return. Since the process runs forever, the ALRM timer fires and rc.subr declares failure even though the daemon is running fine.- Custom
rc_check()usingpgrep -x magiclinks(exact process name) rather than the default full command-line match. rc_pre()removes the stale socket file on every start so restarts don't fail with "address already in use".
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
- The binary is ~13 MB statically linked — no runtime dependencies on the server.
- Token and session stores are in-memory; they reset on restart. This is fine for a personal site — logged-in users will just need to log in again after a restart.
- Go requires the
prot_execpledge promise on OpenBSD even for a fully static binary, because the Go runtime usesmmap(MAP_EXEC)internally. This is a known characteristic of Go on OpenBSD, not a security hole in the service. - The
-devflag runs the service as a plain HTTP server on127.0.0.1:8080for local testing on macOS. Session cookies are not marked Secure in this mode.