The previous post covered getting magic-auth up and running with Docker Compose. This one goes deeper — into the design decisions, security model, and how the moving parts actually fit together. If you've ever wondered what a self-hosted OIDC Identity Provider looks like from the inside, this is that post.
The Server: Go and Nothing Else
magic-auth is written in Go using only the standard library's net/http package — no web framework, no ORM, no router library. This is a deliberate choice. The binary is compiled to a scratch container, meaning the final Docker image contains a single executable and nothing else: no shell, no libc, no package manager, no attack surface beyond the server itself. The result is an image around 10 MB in size.
The schema — users, sessions, clients, tokens, RBAC rules — is created and migrated automatically on startup. There is no manual database setup step. The server supports two storage backends selectable via environment variable: rqlite, a lightweight distributed SQLite over Raft, and PostgreSQL. For most self-hosted deployments rqlite is the simpler choice since it runs as its own container with no external dependencies.
The Magic Link: What Actually Happens
When a user submits their email address, the server does the following:
- Looks up whether the address is registered. If it is not, the response is identical to the success case — a deliberate measure to prevent user enumeration.
- Generates a cryptographically random token, stores a bcrypt hash of it in the database against the user's session record, and constructs a verification URL containing the token and session ID.
- Publishes a JSON payload to NATS JetStream. The server's job ends here — it does not speak SMTP. Whatever consumer you have subscribed to that NATS subject is responsible for delivering the email.
The verification URL contains two parameters: a session ID and a token. When the user clicks the link, the server retrieves the session, verifies the token against the stored bcrypt hash, and then checks the browser fingerprint.
The fingerprint is an HMAC computed from the user's IP address, User-Agent header, and Accept-Language header at the time the magic link was requested. The same HMAC is recomputed at the time the link is clicked. If the values do not match — because the link was opened on a different device, from a different network, or in a different browser — the verification is rejected. This is a security tradeoff worth understanding: it prevents a stolen link from being used from a different context, but it also means a link forwarded from a desktop email client opened on a phone will fail.
Magic links are single-use. Once a token is verified it is deleted from the database. The link is also time-limited to 15 minutes.
JWT Signing: RS256 and ES256
magic-auth issues signed JWTs for all tokens — access tokens, refresh tokens, and the OIDC id_token. Two signing algorithms are supported:
- RS256 (RSASSA-PKCS1-v1_5 with SHA-256) — uses a 2048-bit RSA key pair. Most broadly compatible with third-party libraries and services.
- ES256 (ECDSA with P-256 and SHA-256) — uses a smaller EC key pair. Produces smaller tokens and verifies faster, but slightly less universally supported.
The private key is supplied as a PEM-encoded environment variable at startup. The corresponding public key is exposed via the standard JWKS endpoint at /.well-known/jwks.json, which includes the x5c certificate chain field. Any service that needs to verify tokens can fetch the public key from this endpoint and verify signatures locally without calling back to the IdP.
The OIDC discovery document at /.well-known/openid-configuration points to all the standard endpoints and declares the supported signing algorithms, so compliant clients can configure themselves automatically from a single URL.
Token Lifetimes and Rotation
Token lifetimes are fixed values baked into the server:
| Token | Lifetime |
|---|---|
| Access token | 8 hours |
| Refresh token | 14 days |
| Refresh token renewal window | 7 days |
| Magic link | 15 minutes, single-use |
Refresh tokens rotate on every use. When a client presents a refresh token, the server issues a new access token and a new refresh token, and the old refresh token is immediately invalidated. If a previously revoked refresh token is ever presented again — indicating possible token theft — the server revokes all active sessions for that user immediately. This is the standard refresh token rotation security model described in RFC 6819 and the OAuth 2.0 Security Best Current Practice.
Roles are embedded in the JWT payload at every token issuance and refresh. This means role changes take effect at the next token mint — no logout is required.
The OIDC Layer
magic-auth implements a complete OpenID Connect Authorization Server. The full endpoint surface is:
GET /.well-known/openid-configuration Discovery document GET /.well-known/jwks.json Public key set POST /oauth/register Dynamic client registration (RFC 7591) GET /oauth/authorize Authorization code flow POST /oauth/token Token exchange / refresh GET /oauth/userinfo Claims for the bearer POST /oauth/revoke Token revocation (RFC 7009)
Dynamic client registration (RFC 7591) means new applications can register themselves programmatically with a single API call — no admin portal required for client onboarding. The server supports both confidential clients (server-side apps with a client_secret) and public clients (SPAs and mobile apps using PKCE with no secret).
PKCE (Proof Key for Code Exchange, RFC 7636) is required for public clients. It prevents authorization code interception attacks by binding the authorization request to a secret known only to the initiating client. The code_challenge is a SHA-256 hash of a random code_verifier; the verifier is submitted at token exchange and verified server-side. Even if an attacker intercepts the authorization code, they cannot exchange it without the original verifier.
The PKCE Client Implementation in magic-auth-ui
The companion management UI implements PKCE entirely in the browser using the Web Crypto API — no third-party OAuth library involved. Here is what happens step by step when the UI initiates a login:
- Generate a 256-bit random
code_verifierusingcrypto.getRandomValues - Compute the
code_challengeasBASE64URL(SHA256(verifier))usingcrypto.subtle.digest - Generate a 128-bit random
statefor CSRF protection - Generate a 128-bit random
noncefor id_token replay protection - Store the verifier, state, nonce, and the intended post-login destination in
sessionStorage - Redirect the browser to
/oauth/authorizewith all parameters
On the callback after the user has clicked their magic link:
- Validate the returned
stateagainst the stored value — mismatch means a possible CSRF and the flow aborts - Delete the one-time values from
sessionStorageimmediately - POST the authorization code and
code_verifierto/oauth/token - Verify the returned
id_tokenclient-side: fetch the correct signing key from JWKS bykid, import it viacrypto.subtle.importKey, verify the signature, checkiss,aud,exp,iat, andnonce - Store the access token in JS module memory only — it is never written to
localStorageorsessionStorage - Store the refresh token in
sessionStorage— it survives page reloads within the same tab but is cleared when the tab is closed
The JWKS cache is held in memory and keyed by kid. If a token arrives with an unknown kid — which would happen after a key rotation — the cache is refreshed automatically.
Silent Token Refresh
The access token is kept alive by a proactive refresh timer. When tokens are stored, a setTimeout is scheduled to fire 60 seconds before the access token expires. If the refresh succeeds, new tokens are stored and the timer is rescheduled. If the refresh fails — because the refresh token has expired or been revoked — tokens are cleared and the user is redirected to the login page.
On a full page reload, the in-memory access token is lost. The router's global navigation guard runs auth.init() on the first navigation, which checks for a refresh token in sessionStorage and attempts a silent refresh before deciding whether the user is authenticated. This means sessions survive tab refreshes without prompting the user to sign in again.
Email Delivery via NATS JetStream
Decoupling email delivery from the authentication server is one of the more useful design decisions in magic-auth. Rather than bundling SMTP configuration into the server, magic-auth publishes a structured JSON message to a NATS JetStream subject and leaves delivery entirely to an external consumer.
The payload looks like this:
{
"to": ["user@example.com"],
"subject": "Your sign-in link",
"body": "Click the link below to sign in:\n\nhttps://auth.example.com/api/auth/verify?id=...&token=...",
"is_html": false,
"cc": [],
"bcc": [],
"headers": {
"From": "noreply@example.com",
"X-Mailer": "magiclink-auth",
"X-Token-Type": "magic-link"
}
}
The NATS stream is created automatically on startup if it does not already exist. The stream is configured with a maximum age of 24 hours and a maximum size of 128 MB by default, both overridable via environment variables. This means if your email consumer is temporarily down, messages will be retained for up to 24 hours and delivered when the consumer reconnects — rather than silently dropped.
The consumer can be written in any language. The only contract is: subscribe to the configured subject, deliver the email, call msg.Ack(). If delivery fails, do not ack — NATS will redeliver. Add a dead-letter queue for messages that exhaust retries.
The Role System
Roles are resolved fresh at every token issuance using a four-level priority chain. Given a user and a client, the server evaluates in this order and uses the first match:
- User role override — an explicit per-user, per-client assignment set via the admin API. This is the highest priority and overrides everything else.
- RBAC email rule — a rule matching the user's exact email address for this client.
- RBAC domain rule — a rule matching the user's email domain for this client. Useful for granting all users at a company a specific role without listing each address individually.
- Config default — falls back to
["user"]. Configurable per server.
Rules with client_id="*" match all clients, including direct-flow tokens. This makes it straightforward to grant a global admin role from a single rule without repeating it per client.
Custom roles can be created per client and optionally set as the default role for first-time logins to that client. This allows each application to define its own role vocabulary while still delegating authentication to a central IdP.
Because roles are embedded in the JWT at mint time, the server needs no separate token introspection call to enforce them. Applications can validate the JWT signature locally using the JWKS endpoint and read roles directly from the roles claim.
SSO Session Sharing
The SSO layer is built on top of the standard authentication flow rather than replacing it. When SSO is enabled globally and a client opts in, the server sets an additional cookie — __idp_session — after successful authentication. This cookie is HttpOnly, SameSite=Lax, and scoped to the IdP domain.
On a subsequent login request to another opted-in client, the server checks whether the submitted email matches the active SSO session. If it does, the server skips the magic link step entirely and proceeds directly to issuing an authorization code. If the emails do not match — because the user wants to switch accounts — the normal flow runs regardless.
This design means the user always has to type their email. There is no invisible automatic sign-in. The ability to switch accounts is always present, and the SSO session can never silently sign in under the wrong identity.
The SSO session is cleared on POST /api/auth/logout, POST /oauth/revoke, and GET /logout. The GET /logout endpoint is designed for cross-origin logout redirects — it clears the SSO cookie and then redirects the browser to the URL specified in the redirect query parameter.
Server Configuration Without Restarts
Runtime configuration — SSO toggle, session TTL, registration policy, allowed redirect domains — is stored in the database rather than in environment variables. This means it can be changed via the API and takes effect immediately, with a 30-second cache to reduce database reads. No container restart is needed.
Environment variables still handle secrets and infrastructure concerns: signing keys, database DSN, NATS URL, HMAC secrets. These are genuinely startup-time concerns. The distinction is deliberate: operational configuration belongs in the database, secrets belong in environment variables.
The Direct Magic Link Flow
Not every application needs full OIDC. magic-auth also supports a simpler direct flow for apps that just want session cookies managed by the IdP:
POST /api/auth/request # Submit email, trigger magic link GET /api/auth/verify # User clicks link — cookies are set GET /api/auth/me # Check the current session POST /api/auth/refresh # Rotate refresh token POST /api/auth/logout # Clear all cookies and SSO session
In this flow the server sets access_token, refresh_id, and refresh_token cookies directly on successful verification. There is no authorization code redirect. This is simpler to integrate for server-rendered applications that do not need portable JWTs — though the cookies are still signed JWTs, just delivered as cookies rather than via the token endpoint.
What This Adds Up To
The architectural picture is a small, auditable server with clearly separated concerns: authentication logic in Go, email delivery decoupled via NATS, storage pluggable between rqlite and Postgres, token signing via standard asymmetric keys, and a full OIDC surface that any compliant client can consume without custom integration work.
None of these are novel ideas individually. The value is in how tightly they fit together in something small enough to understand completely, deploy in minutes, and operate without a dedicated platform team.
The Docker images are on Docker Hub:
API: jlcox1970/magiclink-auth
UI: jlcox1970/magiclink-ui
Setup guide: Building a Passwordless Auth System with magic-auth