Authentication
Browsers sign in with a username and password against an encrypted cookie session. Everything else on this page is optional and off by default: a second factor, password reset by email, and a hook for refusing an account outright.
API and mobile clients authenticate with bearer tokens instead — see JSON API.
Sessions
The session is a JSON payload sealed with AES-256-GCM (the key derived from
Config.SecretKey) in an HttpOnly cookie. There is no server-side store, so
nothing needs cleaning up — and rotating SecretKey invalidates every session
at once.
Sessions expire 30 days after they are issued. Signing in rotates the CSRF token, so a token captured before login is useless after it.
Two-factor authentication
Steward implements TOTP (RFC 6238) — the six-digit codes from Google Authenticator, 1Password, Aegis, and friends. It needs no configuration to be available: every user can enrol from their own profile page.
Enrolling
Profile → Two-factor authentication → Set up issues a secret, shows its QR code and the secret in text, and asks for a code to confirm. Confirming turns 2FA on and shows eight single-use recovery codes, once.
The secret is stored when the QR is displayed but 2FA stays off until a code confirms it, so a scan that never completes cannot lock anyone out.
Turning 2FA off, or replacing the recovery codes, requires the account password — a borrowed session cannot quietly strip the second factor.
Signing in
A user with 2FA enabled gets a challenge after their password is accepted:
POST /admin/auth/login → 302 /admin/auth/2fa
GET /admin/auth/2fa → the six-digit prompt
POST /admin/auth/2fa → 302 /admin/A correct password alone grants no access. The half-authenticated state rides in the session cookie as a separate field from the user id, so a session waiting at the challenge is simply unauthenticated — every authorization check in the panel keeps working with no special case. The wait expires after ten minutes.
The challenge accepts either a TOTP code or a recovery code. Attempts are throttled to six per account per minute, before any comparison runs.
Making it mandatory
steward.New(steward.Config{
// ...
Require2FA: true,
})An account that has not enrolled is redirected to its profile page and can reach nothing else until it does; the enrolment flow, logout, and assets stay reachable. During login, a mandated-but-unenrolled account is sent straight to enrolment rather than to a challenge it could not answer.
Bearer-token clients are exempt. They hold an explicit credential that is already separately scoped, and have no session to enrol through.
Properties worth knowing
Important
A code cannot be replayed. The accepted time step is recorded and never accepted twice, so a code read over someone’s shoulder — or lifted from a proxy log — is dead the moment it is used. Codes from earlier steps in the drift window are refused too.
Codes one step either side of the current one are accepted, covering ordinary clock drift between the phone and the server. Recovery codes are single-use, stored as SHA-256 digests (they are 80-bit random values, so stretching buys nothing), and accepted with or without their separators in any case.
The QR code is generated in-process and inlined as an SVG — no external image
service is contacted, which is what the Laravel implementations that call out to
a QR-rendering API get wrong. If a very long Brand overflows the symbol, the
tile falls back to the text secret rather than blocking enrolment.
Schema
Four columns on the users table, added by core migration
0004_add_two_factor_columns: the secret, the confirmation timestamp, the
recovery-code digests, and the last accepted time step. Run migrate up after
upgrading. They are inert when the feature is unused, so rolling the migration
back deliberately does nothing rather than dropping live enrolments.
Password reset
Setting Config.Mailer adds a “Forgot password?” link to the login form:
Mailer: &steward.SMTPMailer{
Host: "smtp.example.com", Port: 587,
Username: "...", Password: "...",
From: "Steward <no-reply@example.com>",
},The reset token is a sealed session payload with a sentinel marker — stateless,
valid for one hour, and invalidated by rotating SecretKey. Only accounts with
an email address can reset, and the response is identical whether or not the
address exists.
Refusing an account
Config.LoginCheck runs after the password (and second factor, if any) has been
accepted and before the session is issued. Returning an error refuses the login
and shows the message on the form:
steward.New(steward.Config{
// ...
LoginCheck: func(ctx context.Context, u *steward.AdminUser) error {
if u.HasRole("suspended") {
return errors.New("This account has been suspended.")
}
return nil
},
})This is the seam for application-level account state — “suspended”, “not yet activated”, “outside permitted hours” — that Steward’s own account model does not carry. It can only withhold a login, never grant one, and it runs before any session exists, so a refused account never reaches the panel or the 2FA challenge.
Note that it does not gate bearer tokens minted earlier: a token is resolved against its owning account on every request, so revoke the token (or delete the account) to cut off an API client.
Roles in code
AdminUser.HasRole(slugs...) answers “who is this?” against the roles the auth
middleware already preloaded, so it costs no query:
if c.User.HasRole("editor", "publisher") { ... }Use it in a policy or a form field’s
Show predicate — places where the rule is a
business rule about rows or fields. It is not a substitute for
permissions, which live in the database
precisely so an operator can re-scope a role without a deploy.
IsAdministrator() is the same check against the built-in administrator
slug.