Saturday, 25 April 2026

Building a Passwordless Auth System with Magic Links (OAuth2/OIDC Included)

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:

  1. 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.
  2. 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.
  3. 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
  • openssl available 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:

  • ISSUER and BASE_URL should be your public-facing HTTPS URL. They must match exactly — they appear in JWT iss claims and magic link URLs.
  • JWT_SECRET and FINGERPRINT_SECRET each need to be at least 32 characters. Use openssl rand -hex 32 to generate them.
  • SECURE_COOKIES: "true" requires HTTPS. For local development set it to "false".
  • The RBAC_RULES variable 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:

  1. User hits a protected route → browser redirects to /login
  2. The app generates a PKCE code_verifier + code_challenge (S256), stores the verifier in sessionStorage, and redirects to /oauth/authorize
  3. magic-auth validates the client and redirects to its built-in login UI (or your custom one via LOGIN_UI_URL)
  4. User enters their email → magic-auth publishes to NATS → your email worker sends the link
  5. User clicks the link → magic-auth opens /api/auth/verify in a new tab, validates the device fingerprint and token, creates an authorization code, broadcasts the callback URL via BroadcastChannel, then closes the tab
  6. The waiting login page receives the broadcast and navigates to /auth/callback?code=...&state=...
  7. The app exchanges the code at POST /oauth/token with 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:

RoleAccess
global_adminFull access to all users, clients, and server configuration
app_adminScoped to their own client — manages users who have logged into their app
userDefault — 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:

RouteRequired Role
/dashboard, /profileAny authenticated user
/admin/users, /admin/rbacapp_admin or global_admin
/admin/clientsglobal_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_SECRET and FINGERPRINT_SECRET — try openssl rand -hex 32
  • Set ISSUER and BASE_URL to your public HTTPS URL — they must match
  • Set ALLOWED_REDIRECT_DOMAINS to restrict which redirect URIs are permitted at client registration
  • Set registration_open: false once all clients are registered, or lock down registration_allowed_domains
  • Store JWK_PRIVATE_KEY in 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