Passwordless Authentication with magic-auth: A Complete Setup Guide
Passwords are a liability. They get phished, reused, breached, and forgotten. Magic links — those one-click sign-in URLs sent to your email — offer a far cleaner user experience with a meaningfully smaller attack surface. magic-auth is a self-hosted, passwordless authentication server and full OpenID Connect (OIDC) Identity Provider written in Go. It handles the entire auth lifecycle: magic link delivery, JWT issuance, token rotation, SSO session sharing, and role-based access control — all from a ~10 MB scratch container.
This post walks through spinning up magic-auth and its companion management UI (magic-auth-ui) using Docker, wiring in an email delivery pipeline, and integrating your own applications via OAuth2/OIDC.
What Is magic-auth?
At its core, magic-auth does three things:
- Issues magic links — a user submits their email address, receives a time-limited, device-fingerprinted URL, and clicks it to authenticate. No password is ever stored or transmitted.
- Acts as a full OIDC IdP — it signs RS256 or ES256 JWTs and exposes all the standard OIDC endpoints, so any app that speaks OAuth2/OIDC can delegate auth to it.
- Manages roles and SSO — a built-in RBAC system lets you assign roles per-user per-app, and an optional SSO session layer lets users skip the email step once they're already signed in to another connected app.
The backend is pure Go using the standard net/http library. Storage is either rqlite (a lightweight distributed SQLite) or PostgreSQL. Email delivery is decoupled via NATS JetStream — magic-auth publishes a JSON message and any consumer you choose handles the actual SMTP/SES/SendGrid delivery.
Architecture Overview
┌─────────────┐ OIDC/OAuth2 ┌───────────────┐
│ Your App │◄─────────────────────│ magic-auth │
└─────────────┘ │ (port 8080) │
└───────┬───────┘
│ NATS JetStream
┌───────▼───────┐
│ Email Worker │ (Node.js / Go / anything)
└───────────────┘
┌─────────────────────┐ ┌───────────────┐
│ magic-auth-ui │◄───────────│ magic-auth │
│ (Admin Dashboard) │ PKCE OIDC │ (port 8080) │
└─────────────────────┘ └───────────────┘
The UI is a separate Vue 3 SPA that authenticates against magic-auth using PKCE, and provides a web-based admin console for managing users, clients, and RBAC rules.
Prerequisites
- Docker and Docker Compose installed
- An SMTP relay, SendGrid, SES, or any email delivery service your worker can call
opensslavailable on your local machine (for key generation)
Step 1 — Generate a Signing Key
magic-auth signs JWTs using either RSA (RS256) or EC (ES256). Generate a key before writing any compose config:
# Option A: RSA (RS256) — most broadly compatible openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \ | openssl pkey -traditional > private.pem # Option B: EC (ES256) — smaller tokens, faster verification openssl ecparam -name prime256v1 -genkey -noout \ | openssl pkey > ec-private.pem
Keep this file safe — it's what makes your JWTs trustworthy.
Step 2 — Docker Compose
Create a docker-compose.yml:
services:
magic-auth:
image: jlcox1970/magiclink-auth:latest
ports:
- "8080:8080"
environment:
ISSUER: "https://auth.example.com"
JWT_SECRET: "use-a-real-32-char-secret-here!!"
FINGERPRINT_SECRET: "another-32-char-secret-here!!!!!"
JWK_PRIVATE_KEY: |
-----BEGIN RSA PRIVATE KEY-----
<paste contents of private.pem here>
-----END RSA PRIVATE KEY-----
DB_DRIVER: "rqlite"
RQLITE_URL: "http://rqlite:4001"
NATS_URL: "nats://nats:4222"
FROM_ADDRESS: "noreply@example.com"
BASE_URL: "https://auth.example.com"
LOG_LEVEL: "info"
SECURE_COOKIES: "true"
RBAC_RULES: '[{"client_id":"*","principal":"you@example.com","principal_type":"email","roles":["global_admin"]}]'
depends_on: [rqlite, nats]
magic-auth-ui:
image: jlcox1970/magiclink-ui:latest
ports:
- "3000:3000"
environment:
VITE_API_URL: "https://auth.example.com"
VITE_CLIENT_ID: "" # fill in after Step 4
VITE_REDIRECT_URI: "https://admin.example.com/auth/callback"
rqlite:
image: rqlite/rqlite:8
volumes: [rqlite-data:/rqlite/file]
command: ["-node-id","1","-http-addr","0.0.0.0:4001","-raft-addr","0.0.0.0:4002"]
nats:
image: nats:2-alpine
command: ["-js"]
volumes:
rqlite-data:
A few things to note:
ISSUERandBASE_URLshould be your public-facing HTTPS URL. They must match exactly — they appear in JWTissclaims and magic link URLs.JWT_SECRETandFINGERPRINT_SECRETeach need to be at least 32 characters. Useopenssl rand -hex 32to generate them.SECURE_COOKIES: "true"requires HTTPS. For local development set it to"false".- The
RBAC_RULESvariable seeds your first global admin. It only applies on first boot when no DB rules exist yet, so it's safe to leave set permanently.
Step 3 — Wire Up Email Delivery
magic-auth does not send email itself. It publishes a JSON message to NATS JetStream on the emails.send subject. You need a consumer that picks that up and calls your email provider. Here is a minimal Node.js example using nodemailer:
import { connect, StringCodec } from "nats";
import nodemailer from "nodemailer";
const nc = await connect({ servers: "nats://localhost:4222" });
const js = nc.jetstream();
const sc = StringCodec();
const transporter = nodemailer.createTransport({
host: "smtp.example.com",
port: 587,
auth: { user: "user", pass: "pass" }
});
const consumer = await js.consumers.get("EMAILS", "email-sender");
for await (const msg of await consumer.consume()) {
const payload = JSON.parse(sc.decode(msg.data));
await transporter.sendMail({
from: payload.headers["From"],
to: payload.to.join(", "),
subject: payload.subject,
text: payload.body,
});
msg.ack();
}
The magic link in payload.body is valid for 15 minutes, single-use, and bound to the browser that made the request via an HMAC fingerprint of IP address, User-Agent, and Accept-Language. A link forwarded to a different device or network will be rejected — a deliberate security tradeoff.
Step 4 — Register the UI as an OIDC Client
With magic-auth running, register the management UI as a public PKCE client:
curl -X POST http://localhost:8080/oauth/register \
-H "Content-Type: application/json" \
-d '{
"client_name": "magic-auth-ui",
"redirect_uris": ["https://admin.example.com/auth/callback"],
"token_endpoint_auth_method": "none"
}'
The response includes a client_id. Copy it into your compose file as VITE_CLIENT_ID and restart the UI container. No client_secret is issued for public clients — PKCE takes its place.
Step 5 — Verify the Stack
# Health check
curl http://localhost:8080/api/health
# {"status":"ok"}
# OIDC discovery document
curl http://localhost:8080/.well-known/openid-configuration
# Public key set
curl http://localhost:8080/.well-known/jwks.json
Open http://localhost:3000 in your browser. You will be redirected to the magic-auth login UI. Enter your admin email and click the link that arrives. You will land in the management dashboard with global_admin access.
Understanding the Sign-In Flow
Here is what actually happens when a user authenticates through the OIDC flow:
- User hits a protected route → browser redirects to
/login - The app generates a PKCE
code_verifier+code_challenge(S256), stores the verifier insessionStorage, and redirects to/oauth/authorize - magic-auth validates the client and redirects to its built-in login UI (or your custom one via
LOGIN_UI_URL) - User enters their email → magic-auth publishes to NATS → your email worker sends the link
- User clicks the link → magic-auth opens
/api/auth/verifyin a new tab, validates the device fingerprint and token, creates an authorization code, broadcasts the callback URL viaBroadcastChannel, then closes the tab - The waiting login page receives the broadcast and navigates to
/auth/callback?code=...&state=... - The app exchanges the code at
POST /oauth/tokenwith the PKCE verifier — no client secret needed
The issued access token contains standard OIDC claims including sub, email, name, roles, iss, aud, exp, and iat. Access tokens last 8 hours, refresh tokens last 14 days with a 7-day renewal window. Refresh tokens rotate on every use — replaying a revoked token triggers immediate revocation of all sessions for that user.
Integrating Your Own Application
Confidential Client (server-side app)
curl -X POST http://localhost:8080/oauth/register \
-H "Content-Type: application/json" \
-d '{
"client_name": "my-app",
"redirect_uris": ["https://my-app.example.com/auth/callback"]
}'
Store the returned client_id and client_secret — the secret is shown only once. Use the standard authorization code flow and exchange the code at POST /oauth/token with your credentials.
Public Client (SPA / mobile — PKCE)
Add "token_endpoint_auth_method": "none" to the registration and include code_challenge and code_challenge_method=S256 in the authorize URL. No client secret is used — the PKCE verifier proves possession at token exchange instead.
Role-Based Access Control
Roles are embedded in the JWT at every issuance and refresh. There are three built-in roles:
| Role | Access |
|---|---|
global_admin | Full access to all users, clients, and server configuration |
app_admin | Scoped to their own client — manages users who have logged into their app |
user | Default — self-service profile only |
You can define custom roles per client and set one as the default for new logins to that client. Role resolution priority (first match wins): explicit per-user assignment → RBAC email rule → RBAC domain rule → config default (["user"]).
# Assign app_admin to everyone at example.com for a specific client
curl -X POST http://localhost:8080/oauth/rbac/rules \
-H "Authorization: Bearer <global_admin_token>" \
-H "Content-Type: application/json" \
-d '{
"client_id": "<client_id>",
"principal": "example.com",
"principal_type": "domain",
"roles": ["app_admin"]
}'
# Test resolution for a specific user + client
curl "http://localhost:8080/oauth/rbac/resolve?email=alice@example.com&client_id=<client_id>" \
-H "Authorization: Bearer <global_admin_token>"
SSO Session Sharing
Once a user is signed in to one magic-auth app, they can skip the email step on other connected apps — they enter their email and are signed in immediately. Both conditions must be true: the global SSO toggle must be on, and the destination client must have SSO enabled.
# Enable SSO globally
curl -X PUT http://localhost:8080/api/admin/config \
-H "Authorization: Bearer <global_admin_token>" \
-H "Content-Type: application/json" \
-d '{"sso_session_enabled": "true", "sso_session_ttl_hours": "168"}'
# Opt a client in
curl -X PUT http://localhost:8080/api/admin/clients/<client_id>/sso \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"enabled": true}'
The SSO session is stored in an HttpOnly, SameSite=Lax cookie scoped to the IdP domain and is cleared on logout, token revocation, and the GET /logout endpoint. The user always enters their email, preserving account-switching ability and preventing silent sign-in under the wrong identity.
The Management UI
magic-auth-ui is a Vue 3 SPA that serves as a full admin console. It authenticates using PKCE — no separate admin password, just the same magic link flow as every other user. Route access is role-gated:
| Route | Required Role |
|---|---|
/dashboard, /profile | Any authenticated user |
/admin/users, /admin/rbac | app_admin or global_admin |
/admin/clients | global_admin only |
From the UI you can manage users, assign roles, create custom roles per client, toggle SSO per client, and update server configuration — all without touching the API directly.
Production Checklist
- Replace
SECURE_COOKIES: "false"with"true"(requires HTTPS) - Use randomly generated 32+ character values for
JWT_SECRETandFINGERPRINT_SECRET— tryopenssl rand -hex 32 - Set
ISSUERandBASE_URLto your public HTTPS URL — they must match - Set
ALLOWED_REDIRECT_DOMAINSto restrict which redirect URIs are permitted at client registration - Set
registration_open: falseonce all clients are registered, or lock downregistration_allowed_domains - Store
JWK_PRIVATE_KEYin a secrets manager (Docker Secrets, Kubernetes Secret, Vault) — not inline in the compose file - Run rqlite with a persistent volume and consider a multi-node cluster for high availability
- Configure your email worker with retries and a dead-letter queue — a failed delivery means a user cannot sign in
Switching to PostgreSQL
If you prefer Postgres over rqlite, update two environment variables and remove the rqlite service:
DB_DRIVER: "postgres" POSTGRES_DSN: "postgres://user:pass@postgres:5432/magicauth?sslmode=require"
Schema migrations run automatically on startup — no manual CREATE TABLE needed.
Summary
magic-auth gives you a complete, self-hosted passwordless auth stack in a single ~10 MB container. Users never touch a password. You get standard OIDC tokens that work with any OAuth2-aware library or middleware. The role system is flexible enough for multi-tenant SaaS apps without being complicated to operate.
The key moving parts are magic-auth itself, rqlite or Postgres for storage, NATS for email queuing, your own email worker, and optionally the management UI. Everything talks over standard protocols — swap out any piece independently as your requirements evolve.
Docker Hub:
API: jlcox1970/magiclink-auth
UI: jlcox1970/magiclink-ui
Magic Auth Deep Dive: Passwordless Auth System with magic-auth, under the hood
No comments:
Post a Comment