CARLOS rastrillo docs

๐Ÿค– Magic links

rastrillo/auth signs people in by emailing them a link. No password to choose, store, or reset.

It is the framework's turnkey option. You get the whole flow (link minting, single-use redemption, rate limiting, sessions and CSRF) and a sign-in screen to use as it is or replace with your own.

Starting a new app? rastrillo new --auth=magic <name> sets all of this up. This page is for adding it to an app you already have, and for how it works.

If you read nothing else on this page, read the trap. It costs people a working app.

Wiring it

Build one *Auth at boot, merge its migrations, mount four handlers:

a, err := auth.New(auth.Config{
	DB:          writer,
	Origin:      origin,
	InstanceKey: instanceKey,
	Proof:       guard, // see "The front door" below
	Mailer:      mailer,
})
if err != nil {
	return nil, err
}

r.Post("/signin", a.Begin)
r.Get("/auth/callback", a.Callback)
r.Get("/auth/verify", a.Verify)
r.Post("/auth/verify", a.Verify)
r.Post("/signout", a.Signout)

Mount Verify on GET and POST. Opening the link draws a Sign in button and spends nothing; the button's POST signs in. The reason is link scanners. Mount only the GET and the button gets a 405: nobody signs in.

The migration order is not optional:

var BootSchema = migrate.Merge(sessions.Schema, auth.Schema, Schema)

auth's backfill migration reads the sessions table, so sessions.Schema has to apply first. migrate.Merge's argument order is apply order โ€” see Migrations.

Use the shipped screen, or your own

Turn on SigninScreen and render ui's signin partial, and your app has a sign-in page on its first day: one field for an address, the right thing happening next whether that address gets a link or Keymail, and a one-tap for someone coming back.

var signinPage http.HandlerFunc // defined below; RenderConfirm draws it too
a, err := auth.New(auth.Config{
	DB:           writer,
	Origin:       origin,
	InstanceKey:  instanceKey,
	Proof:        guard,
	Mailer:       mailer,
	SigninScreen: true,
	// The page an emailed link opens: this screen, with a Sign in button.
	RenderConfirm: func(w http.ResponseWriter, r *http.Request, _ auth.ConfirmPageData) {
		signinPage(w, r)
	},
})

signinPage = func(w http.ResponseWriter, r *http.Request) {
	st := a.SigninState(r)
	st.Passkey = &auth.PasskeyDoor{ // only if you mounted passkey discovery
		BeginPath:  "/passkey/discover/begin",
		FinishPath: "/passkey/discover/finish",
		ModuleURL:  "/passkey/webauthn.mjs", // serves webauthn.JS()
		ScriptURL:  "/passkey/signin.mjs",   // serves passkey.JS()
	}
	a.PrepareSigninResponse(w, st)
	data := map[string]any{"L": signinPages.LocalizerFor(r), "Signin": st, "Brand": map[string]any{"Name": "Harbour"}}
	if err := signinPages.Execute(w, "signin", data); err != nil {
		http.Error(w, "Something went wrong.", http.StatusInternalServerError)
	}
}
r.Get("/signin", signinPage)
r.Post("/signin/forget", a.Forget)
r.Get("/passkey/webauthn.mjs", serveJS(webauthn.JS()))
r.Get("/passkey/signin.mjs", serveJS(passkey.JS()))
// serveJS serves an embedded module with the type a browser requires.
func serveJS(b []byte) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
		w.Write(b)
	}
}

templates/signin.html, in a page set built on the stage shell instead of layout.html:

{{define "title"}}{{$L := localizer $}}{{template "signin-title" (dict "L" $L "State" .Signin "Brand" .Brand)}}{{end}}
{{define "content"}}{{$L := localizer $}}{{template "signin" (dict "L" $L "State" .Signin "Brand" .Brand)}}{{end}}
// templates/stage.html is ui.Layout("stage"), saved beside layout.html.
signinPages, err := ui.NewPages(ui.PagesConfig{
	App:    appFS,
	Layout: "templates/stage.html",
	Pages:  map[string]ui.PageFile{"signin": {Path: "templates/signin.html"}},
	Locale: cfg,
	Funcs:  template.FuncMap{"icon": icons.Icon, "iconAssets": icons.Assets, "asset": assets.Path},
})

SigninScreen turns on everything the screen needs from auth, together: a short-lived cookie that remembers what was typed, so the page can refill the field after a problem and name the address a link went to; a keymail sign-in that stays on your page instead of leaving the form, so the default CSP's form-action 'self' needs no widening; and a cookie that remembers which way this browser got in last. Leave it off and nothing changes: no new cookie, and Begin and Callback answer exactly as before.

Set Remember to a pointer to false to keep the screen and stop remembering, for a kiosk or a shared computer. It also deletes anything remembered before. The remembered cookie is a hint for the screen and nothing else: it signs nobody in, and a one-tap is checked from scratch like a typed address.

If you use passkeys, serve passkey.JS() beside webauthn.JS() and set both addresses on the page's Passkey door, as above; the screen loads the passkey script only when it shows the door. Pass a.RememberJar() as passkey.Config.Remember: a passkey sign-in then clears an address typed earlier in the same browser, and the next visit offers the passkey first.

The stage shell is made for this page: one card over a generated backdrop. Redefine its backdrop block for a pattern or a picture of your own.

Begin and the completion handlers report outcomes by redirecting to Config.SigninPath. The shipped screen reads these for you; a page of your own renders them:

Query Meaning
?sent=1 a link was emailed, or the address was refused; the page cannot tell which
?sent=1&attempt=<id> the same, with the screen on: the id lets the page name the address
?continue=<id> a keymail sign-in is under way; the screen moves on by itself
?err=rate rate limited
?err=address the address was rejected
?err=expired the link had expired or was already used
?err=1 something else went wrong
?err=check&rec=1 the front door refused the post; the page shows a recovery challenge, which SigninState mints for rec=1
?reauth=1 a fresh sign-in is needed to continue

An admission check in front of Begin

If you put an admission check in front of Begin, refusing addresses that are not members before any mail goes out, answer a refusal with a.AnswerAsSent(w, r) rather than a redirect of your own. With the screen on, a sent link leaves a cookie and an attempt= behind; a plain ?sent=1 for a refusal would look different on the very first try, and anyone could learn who is a member. AnswerAsSent checks the challenge first when Proof is set, as Begin does, then answers exactly as a sent link does and sends nothing, so the page and the cookies give nothing away. It does not stop the per-address rate limit or keymail classification from revealing something about an address; that is separate work, and this does not do it.

The front door

auth.New won't start until you decide whether sign-in sits behind pow. Build a Guard and pass it as Proof, or set ProofOff:

guard, err := pow.New(pow.Config{
	InstanceKey: instanceKey,
	Nonces:      pow.SQLNonces(writer),
	Difficulty:  16,
	MinAge:      500 * time.Millisecond,
	ScriptURL:   "/pow" + powAssets.Path("pow.js"),
	WorkerURL:   "/pow" + powAssets.Path("pow-worker.js"),
})

The pow reference covers the rest of building it: apply pow.Schema before pow.New, serve pow.Assets(), and sweep the Guard from a background loop. 16 bits and 500ms are what sign-in needs; Choosing a difficulty says why. If you use passwords too, give both the same Guard.

With neither set, New returns ErrProofUnset, which names both fixes; setting both is an error too. A Guard with Bind on is ErrProofMode, because the sign-in forms carry the address in different shapes and none of them is bound.

With Proof set, Begin checks the challenge first: after the same-origin check, and before the rate limit, classifying the address, or any mail. Someone who hasn't solved it can't make your server look up a domain they chose, send a link, or spend another person's rate budget. Begin uses Check under auth.ProofScope (rastrillo/auth/begin), so the token is spent before anything goes out.

So the sign-in form needs JavaScript. That's deliberate: if a post without a proof were let through, every bot would take that path. A NoProof Guard is accepted, and its button renders enabled, if you want the token and honeypot without the work.

A refusal redirects to ?err=check&rec=1. The shipped screen shows "Your browser couldn't finish a security check. Try again." on the ask step with the address filled in, and rec=1 makes SigninState mint a recovery challenge, one without the honeypot, so a visitor whose password manager filled the trap gets through next time. Every other error redirect from Begin keeps rec=1 when the post was itself a recovery, so the trap doesn't come back after a mistyped address. And every redirect keeps force=1 when the post had it: a visitor who chose "send a link instead" after a failed keymail approval, and was then refused, still gets their link instead of being sent back to the provider that just failed them.

SigninState(r).Proof is the page's challenge. It's set whenever Proof is, with the screen on or off. The signin partial renders it on whichever Begin form the state shows (the fields, a disabled button the module enables, the status line, a <noscript> line, and the script once), and the Forget form carries none of it. A sign-in page of your own renders .Proof as the pow reference shows, renders the hidden force input when .Force is true, and must not be cached: call PrepareSigninResponse, which sets Cache-Control: no-store, or set the header yourself.

Begin logs each refusal at debug level with its reason. Right after an upgrade, missing is a sign-in page that was open before the deploy, and the visitor gets in on their second try.

Configuration worth understanding

InstanceKey must not be empty, and New refuses an empty one. It seals the pending blob with an HMAC, and an empty input hashes to one fixed, publicly computable value โ€” identical across every deployment that made the same mistake.

Origin is the base of emailed links, and what decides the cookie attributes.

Mailer is a mail.Sender. Leave it nil and you get mail.Logged with a warning on every send, because an emailed link is a live credential and logging it is a development-only convenience.

Authorize func(address string) bool is the admission gate: given a verified address, may it have a session? Nil admits every verified address. Membership tables, roles and admin bootstrap are your policy layered on this hook, not something the framework models for you.

Refused func(w, r, Identity) answers an address that verified but that Authorize would not admit. Left nil, the plugin answers a bare-text 403, "This address is verified but not admitted here." โ€” no page you would show anybody. Set it to send people back to your sign-in page with an error of your own and the form for a different address; a redirect to SigninPath with a query of your own is enough. No session is minted. Only someone who has just proved they hold the address gets here, so saying "not admitted" is no oracle.

SecondFactor is the same seam the password plugin has. Passkeys covers it.

The trap: do not use sessions.UserID here

This is the most expensive mistake you can make with this plugin.

The session's Subject is the verified email address, not a numeric id. So:

uid, ok := sessions.UserID(r) // (0, false) โ€” always, under this plugin

And the ordinary scoping seam drops that ok:

func (a *app) owned(r *http.Request) *gorm.DB {
	uid, _ := sessions.UserID(r) // 0
	return scope.Owned(a.db, uid) // WHERE user_id = 0, for everyone
}

Every query in your app is now scoped to user_id = 0. Nothing errors. Users just see an empty app โ€” or, if any row ever gets written with owner zero, each other's data.

Read the viewer with auth.From(r) or sessions.Current(r) instead (RequireSession stashes both), and map the address to your user row's id before scoping:

func (a *app) owned(r *http.Request) *gorm.DB {
	id, ok := auth.From(r)
	if !ok {
		return a.db.Where("1 = 0")
	}
	return scope.OwnedBy(a.db, "user_id", a.userIDFor(id.Address))
}

Guarding routes

r.Use(a.RequireSession)

and a.RequireFreshSession(maxAge) for step-up, matching sessions.Require and sessions.RequireFresh โ€” see Sessions.

Corporate mail goes through a security gateway โ€” Microsoft Defender's Safe Links, Proofpoint URL Defense, Barracuda, a long tail of others. They fetch every URL in an inbound message before the recipient opens it. A single-use sign-in link that redeems on GET is therefore spent by the time the person clicks it, and all they see is "that link has expired". Forward the message and it happens again.

So GET /auth/verify consumes nothing. It draws a page with one button, Sign in, which POSTs the token back; the POST spends the link and runs Authorize, and answers a bad or used link with ?err=expired as before. Scanners issue GETs, not form submissions, so the link survives them. The cost is one extra click.

That is the whole defence. It is deliberately not user-agent sniffing: gateways drive real headless browsers now, and you cannot win that game for long.

Two consequences worth knowing. A link that has already been used still draws the page, and only the button reports ?err=expired, because the GET looks nothing up. And the POST is same-origin-checked like Begin and Signout, so a refused post doesn't spend the link on its way to the 403.

Drawing the page

With SigninScreen on, set RenderConfirm to your sign-in page's handler, as in Use the shipped screen. Inside it, SigninState(r) answers StepConfirm, and the signin partial draws a Sign in button in your layout and language.

Without the screen, leave RenderConfirm nil for a plain built-in page (English, no assets), or draw your own:

a, err := auth.New(auth.Config{
	// ...
	RenderConfirm: func(w http.ResponseWriter, r *http.Request, d auth.ConfirmPageData) {
		page.Confirm(w, d) // d.Token, d.Action, d.Host
	},
})

Your form needs method="post", action set to d.Action, and a hidden field named token holding d.Token. Miss one and nobody signs in.

rastrillo sets Cache-Control: no-store, Referrer-Policy: strict-origin, X-Frame-Options: DENY and frame-ancestors 'none' before calling it. Leave them be. The page's address holds the live link, and strict-origin keeps it out of the Referer your layout's CSS, scripts and images are fetched with. Don't change it to no-referrer: some browsers then send the button's POST with Origin: null, and the same-origin check refuses it. Without the framing pair, someone can frame the page carrying a token minted for their address, coax a click out of your user, and land them in the attacker's account.

A link is consumed in one DELETE ... RETURNING. A split SELECT-then-DELETE would let two concurrent callers both see the row before either deleted it, even at one writer connection, which would defeat single use.

An unknown hash, a wrong purpose and an expired row all come back as the same "not ok"; telling them apart would be an oracle. The row is deleted even when expired, because a presented token is spent either way.

Rate limiting

Every post to Begin that gets past the front door counts against two budgets, whether or not it succeeds: 20 per client IP and 5 per address, each in a fixed 15-minute window that starts with the first attempt. A success doesn't reset either one. Over budget, Begin redirects to ?err=rate.

The IP budget is checked first, so a script hammering the endpoint uses up its own budget before any DNS lookup. The address budget is checked once the address parses, so a typo never spends a real person's allowance. Both are in memory, so they're per process and reset on restart. Behind a proxy, TrustedProxyHops decides which address is the client's.

a.SendLink(ctx, address, ipKey) mails the same link Begin would, for a caller the app already trusts to have decided the address should get one โ€” a request relayed from another service the app holds a key for, say. A server's POST carries no same-origin evidence and no proof, so it cannot post the form, and should not: the proof is the form's defence, and this is for code that has its own.

The link is the same link: the same single-use row, the same letter through Mailer, and the same budgets. ipKey stands where the client address would in the per-IP budget, so pass a fixed name for the channel ("relay"), never an address the caller was told, and that channel shares one budget of 20 per window โ€” the bound on what the service you trust can make you send. The address budget is the form's own, shared with it. No proof is checked, no keymail classification runs (a link is what was asked for), and no attempt cookie is written. A refusal is auth.ErrRateLimited or auth.ErrBadAddress: for a log line, not an answer โ€” a caller that must not be an oracle answers the same whatever comes back.

Without a database

A hub with no database keeps users and sign-in links in an objrec store. Set Records instead of DB, a Pepper of 32 bytes or more, and a Signed key. Sessions are then signed cookies.

guard, err := pow.New(pow.Config{
	InstanceKey: instanceKey,
	Nonces:      pow.RecordNonces(store),
	Difficulty:  16,
	MinAge:      500 * time.Millisecond,
	ScriptURL:   "/pow" + powAssets.Path("pow.js"),
	WorkerURL:   "/pow" + powAssets.Path("pow-worker.js"),
})
if err != nil {
	return nil, err
}
a, err := auth.New(auth.Config{
	Origin:      origin,
	InstanceKey: instanceKey,
	Proof:       guard,
	Mailer:      mailer,
	Records:     store,
	Pepper:      pepper,
	Signed:      &sessions.Signed{Name: "ppl", Key: key},
})
if err != nil {
	return nil, err
}

Keep the pepper secret, and never change it. Every stored address is found by a hash made with it.

Set Blind to store only a hash of each address. The hub can then email a person only when they have just typed their address. Blind needs a Mailer that sends mail: New refuses mail.Logged, which writes addresses to the log.

Give the proof-of-work guard pow.RecordNonces on the same store, and use that store on every instance.

Once a day, call a.Sweep and guard.Sweep to delete expired links and spent nonces.

Records mode lists what the store keeps in each mode.

Aside: the keymail upgrade

rastrillo/auth wraps github.com/keymaildev/signin, and if a submitted address turns out to have a claimed keymail inbox, sign-in upgrades itself to keymail's OAuth ceremony instead of emailing a link.

You almost certainly do not need to think about this. Keymail has a small user base, the upgrade is automatic, and the plugin behaves identically either way from your app's side โ€” same handlers, same session, same Subject.

Two details if you do care. Classification fails open: if the probe fails, the address gets an ordinary magic link, so nobody is locked out by a classifier being unreachable. And two more outcome queries can reach your sign-in page โ€” ?err=keymail when an approval fails, and ?force=1 to offer the plain-email escape hatch after one.

Config.Origin doubles as the OAuth client_id that keymail validates redirects against.

By default any keymail server an address's own domain names is trusted. That is keymail's design: whoever controls a domain chooses its server, the same person who controls its mail and could receive a link anyway, and a server cannot vouch for anyone else's address, because the address it returns must match the one that was typed. To trust a closed set instead, list them in KeymailServers (host or host:port). An address whose server is not listed gets an ordinary link, and its server is never contacted, either to check the address or to finish a sign-in. The list applies with the screen on or off.

Read this page as markdown โ€” exact, unstyled, and cheap for an agent to fetch.