Skip to main content
Version: 1.33.0

Accounts

The part of authentication an end user touches: registering, confirming an address, signing in and out, recovering a password, and being locked out after enough wrong guesses.

It is an opt-in module. Nothing is served until you mount it, and nothing about an application that does not is changed.

Add it: nucleus add accounts​

nucleus add accounts

writes two calls into the nucleus.New() chain of main.go and a block into nucleus.yml:

nucleus.New().
FromConfigFile("nucleus.yml").
WithoutDefaults().
WithStorage().
WithMail().
Mount(accounts.FromRuntime()).
Start()
mail_driver: log
modules:
accounts:
base_url: http://localhost:8080
from: no-reply@example.com

accounts.FromRuntime() builds the flows at start-up out of what the application already has: the account tables on its default database (created if missing; SQLite, PostgreSQL or MySQL), its mail sender, and its session manager. WithMail() is what gives an application built WithoutDefaults() — the api starter — a mail sender at all; on the default stack it changes nothing.

Run it, register, and the confirmation link is in the terminal:

curl -X POST localhost:8080/auth/register -H 'Content-Type: application/json' \
-d '{"email":"ana@example.com","username":"ana","password":"correct horse battery staple"}'
# level=WARN msg="mail not delivered: mail_driver is log (development only)" to=ana@example.com
# subject="Confirm your email address" body="Open http://localhost:8080/auth/verify-email?token=… to confirm …"

Sign-in answers 403 until the address is confirmed; open the link, and it answers 200.

Mail has to go somewhere, or the application does not start​

The flows mail the link that confirms an address and the one that resets a password. A sender that delivers nothing would make registration answer 202 while every account stayed unconfirmable, so FromRuntime checks at start-up and refuses, naming what to set:

  • no mail sender at all (an application built WithoutDefaults() without WithMail());
  • mail_driver: noop, the default, which accepts every message and delivers none.

mail_driver: log is the development answer the recipe writes: every message — the link in it included — is written to the application log at WARN and none is delivered. It is refused outside env: development, at configuration load and again at boot, because a log is shipped to places a password-reset link must never reach. Before the application faces anyone, set mail_driver: smtp with smtp_host and smtp_port, or a nucleus-plugin-<driver> (Mail).

modules.accounts​

KeyDefaultWhat it is
base_url— (required)The address the browser reaches the application at; the mailed links point there. It is configuration, not the request's Host header, which an attacker chooses.
from— (required)The sender of account mail.
allow_unverified_loginfalseLet an account sign in before its address is confirmed.
min_password_length12
issuerNucleusWhat an authenticator app shows next to a code.
mfa_key_env—The environment variable holding the key second factors are encrypted with: 32 bytes, base64 (openssl rand -base64 32). Without it second factors are refused; a name whose variable is unset or malformed fails start-up.

Who a signed-in account is​

StartSession records the account's id under SessionKeyAccountID and under auth.SessionKeySubject, which is what the framework's default-deny layer reads. A signed-in session is authorised as its account id — the same subject a bearer token with that user id is, and an API key the account owns (nucleus apikey create --owner <account id>). A policy row or a role for the id applies to all three:

g, <account id>, member
p, member, /app/*, *, allow

The session keeps that subject until it ends: disabling an account stops its next sign-in, not the sessions it already has — Service.RevokeSessions ends those, as a password reset does.

On the default stack FromRuntime grants the anonymous subject its own routes — the person registering or signing in has no identity yet. The routes that need one (a password change, a second factor) check the session themselves. With csrf_enabled the session-cookie routes are protected like any other.

Build it yourself: accounts.Module​

When the account table is your own, the mail goes through a queue, or the templates are yours, build the service and mount Module:

store, err := accounts.NewSQLStore(ctx, db, accounts.SQLStoreConfig{
Flavor: accounts.FlavorPostgres,
})
svc, err := accounts.New(store, nil, mailer, templates, accounts.Config{
BaseURL: "https://app.example.com",
From: "no-reply@example.com",
RequireEmailVerification: true,
}, logger)

app := nucleus.New().Mount(accounts.Module(svc))

The module takes the application's own session manager at start-up, so a service built with a different one cannot end up writing into a session the request never carries.

The routes​

MethodPathWhat it does
POST/auth/registerCreate an account and send a confirmation link
GET/POST/auth/verify-emailConfirm an address with the link's token
POST/auth/loginSign in and start a session
POST/auth/logoutEnd the session
POST/auth/password/resetSend a reset link
PUT/auth/password/resetSet a new password with the link's token
POST/auth/password/changeChange the signed-in account's password
POST/auth/magic-linkSend a one-time sign-in link
GET/auth/magic-linkSign in with it

The handlers are thin on purpose. Every decision that matters is in Service, tested once, instead of in each application's copy of the same seven handlers.

What the flows decide for you​

A registration form does not say whether an address is taken. It answers 202 with the same body either way, and mails the existing owner a confirmation link instead. An endpoint that answers differently is an account enumeration oracle — the most-used one on the web. The reset and magic-link endpoints keep the same property.

A wrong password and an unknown account are indistinguishable, in the error and in the time it takes: an unknown address is still verified against a dummy hash, because answering faster is the same disclosure by another channel.

Tokens are single-use, stored hashed, and scoped to a purpose. The row holds SHA-256 of the token, so a leaked database does not hand over live reset links; consuming one is a single conditional UPDATE, so two concurrent uses of the same link cannot both win; and a token issued to verify an address cannot reset a password.

A reset ends the account's sessions and drops every other outstanding reset link. A recovery that leaves the attacker signed in has not recovered the account.

Signing in rotates the session token, which is what closes session fixation.

A password change requires the current password — a session left open on a shared machine must not be enough to take the account over — and changes the account in the SESSION, never one named in the body.

GET /auth/magic-link?token=… consumes the token, because that is what makes a link work in one click. Mail clients, security scanners and link previewers fetch URLs they find in mail, and a fetch is indistinguishable from a click: the token is spent and the person who received it arrives to an expired link.

The framework cannot tell the two apart, so it does not pretend to. Two things reduce the cost, and both are yours to choose:

  • keep the TTL short (15 minutes by default) so a spent link is quickly replaced by asking again;
  • if your users are behind a scanner that does this, serve your own page at the link and have it POST the token, so the fetch is harmless and the click is what spends it. The token travels in the JSON body as well as the query string, precisely for that.

Lockout​

Failures are counted per identity within a window; past the threshold the endpoint answers 429 (not 401: a client that retries on 401 would hammer a locked account), and it answers that way for the correct password too. A lockout that lets the right password through only slows down an attacker who is already wrong.

accounts.Config{
LockoutThreshold: 10, // default
LockoutWindow: 15 * time.Minute, // default
}

A successful sign-in clears the counter.

What the failures are counted against has no free answer. Per account is what ASVS asks for and what stops credential stuffing against one user — and it lets anyone lock a known address out for the window by typing ten wrong passwords, which is a denial of service against that person. Per client stops that and lets a botnet spread its guesses.

So the framework does not choose silently. Login counts per account. LoginFrom folds the caller in, and the mounted module uses it with the client address: the attacker locks out themselves, the owner still signs in, and a distributed attack still meets the per-identity rate limiter.

svc.LoginFrom(ctx, email, password, accounts.ClientIP(r))

Where accounts live​

Store is the port. NewSQLStore is an implementation with its own three tables (nucleus_accounts, nucleus_account_tokens, nucleus_account_failures) for an application that does not have a users table yet; an application that does implements the same interface against it.

Whatever implements it must keep three properties, because the flows rely on them: Create reports a duplicate rather than overwriting, ConsumeToken is atomic, and RecordFailure returns the count including the failure it just recorded.

SQLStore.PurgeExpired drops used and expired rows; call it from a scheduled job. Nothing depends on it for correctness — every read already filters on time.

Mail​

Mailer is one method, so you can hand it the direct sender or a queue-backed one. Prefer queueing: a confirmation mail that goes out while the transaction rolls back points at an account that does not exist. See Queueing mail with the write it announces.

Supply mail.Templates for the wording (verify_email, reset_password, magic_link); without them the service sends a plain-text fallback, so the flows work before anybody writes a template.

Second factor​

TOTP is implemented against RFC 6238 in about sixty lines of HMAC and a counter, rather than pulled in as a dependency — the framework is stdlib-first, and a dependency that small is a supply-chain entry for every application that links it. The RFC's own test vectors are in the suite, so any authenticator app agrees.

Second factors need a key:

accounts.Config{
Issuer: "Example Co", // what the app shows next to the code
MFAEncryptionKey: key, // exactly 32 bytes
}

Without it, enrolment is refused. A TOTP secret is a password equivalent — anyone holding it mints codes forever — so storing it in the clear would work perfectly and fail once, silently, in a database backup. Secrets are sealed with AES-256-GCM and a per-secret nonce.

The routes​

MethodPathWhat it does
POST/auth/mfa/totpStart enrolment: returns the secret and the otpauth:// URI to show as a QR code
PUT/auth/mfa/totpConfirm with the first code; returns the recovery codes
DELETE/auth/mfa/totpRemove the factor and its recovery codes
POST/auth/mfa/verifyFinish a sign-in that stopped at the password
POST/auth/mfa/recovery-codesIssue a fresh set, invalidating the old one

The sign-in becomes two steps​

With a confirmed factor, POST /auth/login answers 401 with {"mfa_required": true} and records a pending sign-in under a different session key from a finished one — so nothing that reads the signed-in identity mistakes one for the other. POST /auth/mfa/verify completes it.

401 rather than a 2xx is deliberate: a client that treats any 2xx as "signed in" would act on a half-authenticated session.

Properties worth knowing​

  • Enrolment is not active until confirmed. A secret nobody has proven they can read must not lock an account out of its own sign-in.
  • A one-time password is one-time. The accepted counter step is recorded and a code at or below it is refused, so a code read over someone's shoulder does not work for the rest of its thirty seconds.
  • Recovery codes are single-use, hashed, and forgiving about typing — case and dashes are normalised before hashing. They are shown once.
  • A wrong code counts towards the lockout. A second factor that can be guessed without limit is a six-digit password.
  • TOTP is tried before recovery codes, so a mistyped code does not burn one.

Step-up​

Changing a second factor requires a sign-in from the last 15 minutes, not merely a session that still resolves: a session open for three weeks is evidence somebody signed in three weeks ago, and nothing about who is at the keyboard now. Service.RequireFreshAuth(ctx, maxAge) is the same guard for your own sensitive operations, and MarkAuthenticated stamps a fresh proof.

SessionManager.HasSession(ctx) answers whether a context carries session data at all — a handler mounted outside the session middleware gets an error instead of a panic from deep inside the session library.