sulis

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 20, 2026 License: MIT Imports: 22 Imported by: 0

README

sulis

sulis is a small Go authentication library for consumer-owned persistence. The root package provides password-based auth, password reset, magic-link login, two-factor pending-login tokens, email verification, server-side sessions, and HTTP middleware for attaching the authenticated user and session to a request context. The totp, passkey, and recovery subpackages add TOTP, WebAuthn passkeys, and recovery codes as second factors or standalone credentials, and passwordcheck screens new passwords against known-compromised values. Because you own persistence, the storetest package ships a conformance suite that proves your store implementations satisfy the contracts the library depends on, and memstore is a reference in-memory implementation of all of them — see Proving your stores correct.

Requires Go 1.26.6+ (matching go.mod).

Root Package

Create a service with sulis.New(userStore, sessionStore, tokenStore, secondFactorChecker, opts...), which returns (*Sulis, error).

secondFactorChecker is required and must not be nil. It is how the library learns that a user has a second factor, and defaulting it would mean silently issuing fully-privileged sessions to accounts that expect two-factor authentication:

type SecondFactorChecker interface {
    HasSecondFactor(ctx context.Context, userID string) (bool, error)
}

Implement it against whatever your application counts as a second factor — a verified TOTP enrollment, a registered passkey, or both. Applications with no second factors pass sulis.NoSecondFactors{}, which states that in code rather than by omission.

The root package owns the auth logic and data types:

  • User, Session, and Token
  • Register, Login, VerifyPassword, IssueSession, IssueSessionUnchecked, ChangePassword, SetInitialPassword
  • ValidateSession, RevokeSession, RevokeAllSessions
  • ListUserSessions, RefreshSession
  • RequireRecentAuth, ReAuthenticate
  • DisableUser, EnableUser
  • CreatePasswordResetToken, CreatePasswordResetTokenStrict, ResetPassword
  • CreateMagicLinkToken, RedeemMagicLink
  • CreateTwoFactorToken, CompleteTwoFactor
  • CreateEmailVerificationToken, VerifyEmail
  • ChangeEmail, ConfirmEmailChange
  • Authenticate, UserFromContext, SessionFromContext
  • SessionCookie, ClearSessionCookie, RequireSameOrigin
  • IssueCSRFToken, RequireCSRFToken, VerifyCSRFToken
  • WithEventSink, NewSlogSink, EventSink, Event, EventKind

Password hashes use Argon2id over the NFKC-normalized password, optionally peppered first — see Peppering. New passwords are screened for length and against a corpus of known-compromised values — see Password quality. Reset, magic-link, two-factor, and email-verification tokens are random, single-use, purpose-scoped, and time-limited.

Core Flows

Register

Register(ctx, email, password, requestInfo) returns (*User, *Session, string, error) — the third value is the raw session token. It normalizes and validates the email, puts the password through the policy (length, then the configured PasswordChecker — see Password quality), hashes the password, creates the user, and immediately creates a new session. It returns ErrUserAlreadyExists if the email is already taken, ErrInvalidEmail for malformed/empty/overlong addresses, ErrPasswordTooShort/ErrPasswordTooLong if the password falls outside the configured bounds, and ErrPasswordCompromised if the checker recognizes it. Registration does not mark the email as verified — only a redeemed magic link or a completed VerifyEmail does that.

Password quality

Every path that stores a password — Register, ChangePassword, ResetPassword, SetInitialPassword — puts it through the same three steps, in this order:

  1. NFKC normalization. The password is folded to its Unicode NFKC form before anything else looks at it, and that form is what gets hashed. Without this, whether a password verifies depends on which keyboard typed it: café arrives from macOS as e plus a combining acute accent and from Windows as the single precomposed rune, and those are different byte strings with different Argon2 hashes. NFKC rather than NFC so the compatibility mappings fold too — the ligature, fullwidth digits, and friends, which are one keystroke on some input methods and plain ASCII on others.
  2. Length. MinPasswordLength (default 12, raised from 8 in this release) and MaxPasswordLength (default 1024), measured in bytes of the normalized password, since that is what Argon2 actually consumes. Twelve fullwidth digits are 36 raw bytes and 12 normalized ones; measuring the raw form would wave a password through a minimum it does not meet. WithPasswordLengthLimits(min, max) changes both.
  3. The PasswordChecker. Length alone lets iloveyou1234 through. The configured checker gets the normalized password and returns ErrPasswordCompromised to reject it.
type PasswordChecker interface {
    Check(ctx context.Context, password string) error
}

A checker is configured by default. sulis.New installs passwordcheck.NewBlocklist(), which compares against an embedded corpus of the ten thousand most common passwords: no network, no third party, nothing to switch on. Comparison folds case, since an attacker's dictionary does too. Note the interaction with the raised minimum, though — common passwords are short, so at MinPasswordLength 12 only ten of those ten thousand entries are even reachable; the length gate rejects the rest first. The blocklist earns its keep when you lower the minimum, and when you pass site-specific words to NewBlocklist("Acme-Corp", "acme-stadium", ...) — exactly the long passwords a targeted attacker tries first, which no general corpus can know about.

For real breadth, add Have I Been Pwned. passwordcheck.NewHIBP() queries the range API using k-anonymity: only the first five hexadecimal digits of the password's SHA-1 leave the process, and the match against the several hundred suffixes that come back is made locally. The password, its full hash, and even the hash's suffix are never transmitted — a property pinned by a test that inspects the entire outbound request and fails if any of the three appears in it. Compose rather than replace, or you silently drop the local blocklist:

s, err := sulis.New(users, sessions, tokens, factors,
    sulis.WithPasswordChecker(passwordcheck.All(
        passwordcheck.NewBlocklist(),   // local, free, always available
        passwordcheck.NewHIBP(),        // network, opt-in
    )),
)

HIBP fails open by default. If the service is unreachable — connection refused, timeout, 5xx, 429 — the password is allowed through unchecked rather than rejected. The alternative makes another organization's uptime a hard dependency of your registration and password-reset flows, including the reset someone is doing because they were just breached; failing open costs a few unscreened passwords during an outage, and they still face the length policy and the local blocklist. passwordcheck.WithHIBPFailClosed() inverts it for deployments whose policy demands it — pair it with alerting, because "nobody can change their password" is the failure mode to actually fear. Either way, an unreachable service produces an ordinary error and never ErrPasswordCompromised: surface it as "try again in a moment", not as "your password is compromised", because nobody looked. A response row that matches the queried suffix but carries an unparsable count — corrupted mirror or tampering middlebox territory — is one such error too, and it wraps passwordcheck.ErrMalformedResponse specifically so an application can branch on errors.Is for custom handling of that one case without a new option. WithHIBPBaseURL, WithHIBPTimeout (default 5s, applied to the request context), and WithHIBPHTTPClient cover self-hosted mirrors, tests, and shared transports.

Pass WithPasswordChecker(nil) to disable screening entirely.

Verification never consults the checker. VerifyPassword, Login, and ReAuthenticate do not screen the password they are checking, and cannot return ErrPasswordCompromised. Screening at verification time would lock out every existing user whose password is in the corpus the moment one is added or refreshed — a hardening change turned into a mass outage whose only remedy is itself a login-adjacent flow. A password is screened where it is chosen. If you want existing users moved off a now-known-bad password, detect that out of band and require a change, which leaves the user in control of when it happens.

Upgrading: what NFKC normalization does to existing hashes

A hash written before this release was derived from the raw bytes the user typed, not from the NFKC form. Nobody is locked out. Verification tries the normalized form first and, only if that fails and the two forms differ, falls back to comparing the raw bytes — so a pre-existing hash still verifies against the spelling it was created from. A match that way is treated exactly like a hash with outdated Argon2 parameters: it is re-derived from the normalized form and written back on the spot, through the same best-effort, concurrency-guarded path described under Login and VerifyPassword. After one successful login (or one ReAuthenticate) the account is migrated, every equivalent spelling of its password starts working, and the fallback never fires for it again.

The fallback widens nothing: it compares the caller's exact bytes against the stored hash, so the only password it can accept is the one that hash was already derived from. No string that failed before this change succeeds after it. It also costs nothing for an already-normalized password — every ASCII password, so very nearly all of them — because there is no second form to compare.

Two things genuinely do change for existing deployments. The default minimum length is now 12, so accounts with shorter passwords keep working but cannot re-use them on a change or reset; set WithPasswordLengthLimits(8, 1024) to keep the old behavior. And ChangePassword/ResetPassword can now fail with ErrPasswordCompromised, which your handlers should present as "choose a different password" rather than as a credential error.

Login and VerifyPassword

Login(ctx, email, password, requestInfo) treats a correct password as the first factor only. It verifies the password, then asks the configured SecondFactorChecker whether the account has a second factor enrolled, and returns a *LoginResult:

res, err := auth.Login(ctx, email, password, sulis.RequestInfo{IP: ip})
if err != nil {
    return err
}
if res.NeedsSecondFactor {
    // No session exists yet. Stash res.PendingToken server-side, prompt for
    // the second factor, then call CompleteTwoFactor.
    return promptForSecondFactor(res.User, res.PendingToken)
}
setSessionCookie(res.SessionToken)

Exactly one outcome is populated: either Session + SessionToken, or PendingToken with NeedsSecondFactor set. Branch on NeedsSecondFactor — treating a non-nil LoginResult as "logged in" defeats two-factor authentication.

If the checker returns an error, Login fails closed: no session, no pending token, error propagated. An unavailable factor store must never silently downgrade an account to one factor.

VerifyPassword(ctx, email, password, requestInfo) checks credentials without creating anything, for applications that want to drive the second-factor step entirely themselves.

Both equalize response timing for unknown-user and passwordless-user cases by running the same Argon2 work against an internal dummy hash, and both return ErrInvalidCredentials for any of: unknown email, passwordless account, or wrong password — the error never reveals which. If a Limiter is configured (see Operational requirements), it is consulted before the store lookup, keyed by "password:"+<normalized email>.

Raising Argon2Params upgrades existing hashes transparently as users log in. Both Login and VerifyPassword compare the just-verified hash's cost parameters (memory, iterations, parallelism) against the currently configured Argon2Params and, if the stored hash is weaker, re-hash the password that was just verified with the current parameters and write it back — no migration script, no forced password reset, and no change to either method's signature or return value. This only ever runs after a successful verification, so a wrong password, an unknown email, or a passwordless account never triggers a rehash and the timing-equalization story above is unaffected. The write is best-effort: it goes through the same optimistic-concurrency retry (UserStore.UpdateUser's Version contract) every other password write uses, guarded so a password changed by a concurrent request is never overwritten with a rehash of the password it just replaced, but if the write is lost to a losing race or a store error, the login still succeeds — only the cost of the next login is affected, and that next successful login simply tries the upgrade again. Lowering Argon2Params does not downgrade existing hashes; only a hash weaker than the configured parameters is ever rewritten. ReAuthenticate (see Step-up authentication) does the same on its own successful password comparisons; RedeemMagicLink and IssueSession/IssueSessionUnchecked never verify a password at all, so there is nothing for them to upgrade.

IssueSession(ctx, auth) returns (*Session, string, error) and creates a new session for the user named by an Authentication proof. Authentication is opaque — its fields are unexported, and there is no exported constructor that takes a bare user ID — so only this package can produce a valid value, and IssueSession rejects the zero value Authentication{} with ErrNotAuthenticated before touching any store. Beyond that check it behaves like IssueSessionUnchecked below: ErrUserNotFound if the proof's user no longer exists, and — by default — ErrEmailNotVerified if the account's email isn't verified yet; see Operational requirements for the RequireVerifiedEmail flag. Login applies the same verified-email gate before consulting the second-factor checker, so a correct password for an unverified account returns ErrEmailNotVerified rather than a session or a pending token.

IssueSession deliberately does not consult the SecondFactorChecker: it is the primitive for a login that has already cleared every factor. That is also why there is no way to obtain an Authentication from outside this package: minting one is the guarantee that every factor was checked, and only sulis itself is in a position to make that claim.

IssueSessionUnchecked(ctx, userID, method) returns the same (*Session, string, error) and applies the same ErrUserNotFound/ErrEmailNotVerified gating, but takes a bare user ID instead of an Authentication — it is IssueSession's old, unguarded behavior kept under a name that shows up in code review. Call it only for a factor sulis does not know how to verify itself — the canonical example is a finished passkey ceremony, which is verified entirely by the passkey subpackage and has no way to produce an Authentication. Calling IssueSessionUnchecked means you, not sulis, are vouching that userID completed every factor your application requires; sulis performs no credential check of its own here. method records which credential you're vouching for.

ChangePassword(ctx, userID, oldPassword, newPassword, requestInfo) is for accounts that already have a password. It consults the configured Limiter (key "password:"+email, the same key Login/VerifyPassword use) before verifying the old password, so a stolen session token can't be used to brute-force it once rate limiting is enabled. It returns ErrInvalidCredentials for a passwordless account or a wrong old password. The old password is re-verified against the freshly loaded user as part of the write, so a password changed by a concurrent request is never overwritten on the strength of a stale check.

SetInitialPassword(ctx, userID, newPassword) is for passwordless accounts created through flows such as magic link. Call it only after your application has already authenticated the user through a trusted flow; it returns ErrInvalidCredentials if the account already has a password.

Peppering

WithPepper(pepper []byte) mixes a secret pepper into every password via HMAC-SHA256 before Argon2 ever sees it:

s, err := sulis.New(users, sessions, tokens, factors,
    sulis.WithPepper(loadPepperFromSecretsManager()),
)

What it protects against, and what it doesn't. A pepper is not stored anywhere near the password hashes — unlike a salt, which travels with the hash it protects. It defends against a database-only leak: a copy of the user table with no access to your application's configuration or secrets yields Argon2 hashes nobody can run an offline dictionary attack against without also having the pepper. It does not protect against a full application compromise — the same process that hashes and verifies passwords holds the pepper, so an attacker who reaches that process reaches both.

Losing the pepper makes every hash unverifiable. There is no fallback. Store it with the same care as a private key: a secrets manager or environment variable, never checked in, never beside the database it is meant to protect.

Set it before the first password is ever hashed, or plan on password resets. A pepper is a first-deployment decision, not a knob to turn on a running system. verifyPassword applies whichever pepper is currently configured, uniformly, to both forms its NFKC compatibility fallback already tries — it does not also try every pepper this deployment has ever used. That fallback is safe to widen because it can only ever match the exact bytes a hash was already derived from; a pepper introduced later, changed, or removed has no such single "old form" to fall back to, only an unbounded list of past values this library has no way to know. Concretely:

  • Introducing a pepper on a deployment that started without one makes every existing hash unverifiable.
  • Changing a pepper's value makes every hash written under the old value unverifiable.
  • Removing a pepper makes every hash written while one was set unverifiable.

In every case, the recovery path is the same one already used for a lost password: reset it. This is a deliberate choice, not a gap — building dual-path verification the way T505's NFKC fallback works would mean guessing which of an unbounded set of historical peppers produced a given hash, which is a fundamentally different (and unsafe) problem from "try the one other form a password can take."

ValidateSession

ValidateSession(ctx, token) hashes the presented token, loads the session by hash, rejects expired sessions with ErrSessionExpired (deleting the expired record as it goes), and returns the session plus its user.

Session has no Token field. The raw token exists only as a return value at issue time — LoginResult.SessionToken, or the third result of Register, IssueSession, IssueSessionUnchecked, and RefreshSession — so the struct handed to SessionStore has no way to carry it and no store can persist a live bearer token by accident. Stores see TokenHash and nothing else.

RevokeSession(ctx, userID, sessionID) is scoped to the caller's own userID. It deletes sessionID only if it belongs to userID, returning ErrSessionNotFound (and leaving the session untouched) otherwise — so a session-management UI wired straight to this method can't let one user revoke another's session by guessing or leaking its ID. Pass the userID of the account the caller is authenticated as, not a value taken from the request body. RevokeAllSessions(ctx, userID) deletes every session for a user and has no such ambiguity to begin with.

Idle expiry is opt-in via WithIdleTimeout(d). A session unused for longer than d is rejected by ValidateSession with ErrSessionExpired even if its absolute SessionDuration lifetime has not elapsed yet — useful for "sign me out after 30 minutes of inactivity" on top of a long-lived SessionDuration. Passing d <= 0 (the default; no option needed) disables idle expiry entirely: IdleExpiresAt stays nil forever and ValidateSession never checks it.

"Unused" is tracked by Session.LastSeenAt/IdleExpiresAt, stamped at issuance and refreshed by ValidateSession on every successful call — but throttled, not written every time: a write only happens once the session's current LastSeenAt is already older than an interval (IdleTimeout / 4 when idle expiry is configured, so a session in steady use never drifts more than a quarter of the timeout behind reality; a fixed 5 minutes when it isn't, purely to bound staleness for the "where you're signed in" screen below). Skipping this throttle would mean a store write on every single authenticated request — for most applications, on nearly every request they serve. The touch is best-effort: a failed write never fails the validation itself, since the session is still valid regardless of whether its "last seen" bookkeeping happens to update at that exact moment.

Authenticate reads the session token from whichever channel(s) TokenSource permits (default TokenSourceBoth: an Authorization: Bearer header or a cookie), then calls ValidateSession and attaches the result to the request context. This section is what to wire up if you want the cookie half of that to actually work, plus the CSRF defense a cookie needs and a Bearer header doesn't.

SessionCookie(rawToken, expires) *http.Cookie and ClearSessionCookie() *http.Cookie build the Set-Cookie your handler needs — http.SetCookie(w, auth.SessionCookie(res.SessionToken, expiresAt)) at login, http.SetCookie(w, auth.ClearSessionCookie()) at logout. Every security attribute is fixed by these methods, not left to you: HttpOnly, Secure, SameSite=Lax, Path=/, and a Name that defaults to "__Host-session". The __Host- prefix is a browser-enforced guarantee — this cookie can only have been set by this exact origin, over HTTPS, for the whole origin — that requires exactly Secure, Path=/, and no Domain attribute; both methods always set the first two and never set the third, for any CookieName, so the combination can never quietly become invalid. WithCookieName overrides the name; picking one without the __Host- prefix is a valid, explicit opt-out (e.g. if a reverse proxy in front of you needs to also read it via a shared Domain), and New rejects a name that isn't a valid cookie-name token at all (empty, or containing whitespace/control characters).

TokenSource and WithTokenSource decide which channel(s) Authenticate reads. The default, TokenSourceBoth, is today's behavior and stays the default even though this same release adds cookie support and the CSRF defenses below: a Bearer header is never attached to a request by a browser on its own, so accepting one alongside a cookie doesn't create or widen a CSRF exposure by itself — that exposure comes entirely from the cookie channel, and closing it is exactly what RequireSameOrigin/RequireCSRFToken are for. TokenSourceCookieOnly makes Authenticate never read the Authorization header at all; TokenSourceBearerOnly makes it never read the cookie. A deployment that sets TokenSourceBearerOnly, and never calls SessionCookie, needs neither RequireSameOrigin nor the CSRF helpers below — without a cookie there's no ambient credential for a forged cross-site request to ride on.

RequireSameOrigin(allowed []string) func(http.Handler) http.Handler rejects a cross-site, state-changing request (any method other than GET/HEAD/OPTIONS) using the Fetch Metadata Sec-Fetch-Site header, falling back to Origin when Sec-Fetch-Site is absent; allowed lists origins ("https://app.example.com") trusted even when the browser reports cross-site. Wrap it around any mux/handler reachable via a cookie-sourced session:

mux.Handle("/api/", sulis.RequireSameOrigin([]string{"https://app.example.com"})(apiHandler))

When both Sec-Fetch-Site and Origin are absent, the request is allowed through. Every browser new enough to send either header sends at least one of them on a cross-site request; a request with neither is the signature of a non-browser client — a Bearer-token API caller, in particular, which was never CSRF-exploitable to begin with. Rejecting on absence would block exactly that population for no CSRF benefit. This leaves a narrow residual gap — a pre-Fetch-Metadata browser that also omits Origin on some cross-site state-changing request — which is what the double-submit helpers below cover independently of either header.

IssueCSRFToken, RequireCSRFToken, and VerifyCSRFToken are the double-submit half of the defense, and work whether or not RequireSameOrigin is also wired up. IssueCSRFToken() (token string, cookie *http.Cookie, err error) mints a random token; set the cookie it returns and hand the token to the page (a hidden form field, or a value for a same-origin script to mirror into the X-CSRF-Token header — CSRFHeaderName). RequireCSRFToken is middleware that checks state-changing requests the same way RequireSameOrigin does (safe methods pass through); VerifyCSRFToken(r) error is the underlying check, exported separately for handlers that want to run it themselves. Either way, the rule is: the value in the CSRFCookieName cookie ("__Host-csrf_token", deliberately not HttpOnly — a same-origin script has to be able to read it) must match, byte for byte, whatever the client echoed back in CSRFHeaderName or (falling back, for a plain <form> POST) the CSRFFormField form value. The comparison is constant-time (crypto/subtle.ConstantTimeCompare), so a timing side channel can't be used to recover the token. A mismatch, a missing cookie, or a missing echoed value all fail the same way (ErrCSRFTokenInvalid / 403 Forbidden) — deliberately: telling an attacker which one failed would leak a bit about a cookie they can't otherwise read.

None of this applies to a Bearer-only deployment. If TokenSource is TokenSourceBearerOnly and you never call SessionCookie, skip RequireSameOrigin and the CSRF helpers entirely — there is no cookie for either defense to protect. Add them back the moment you call SessionCookie for any route, even one that also accepts a Bearer header.

Authenticate's 401 response carries WWW-Authenticate and Cache-Control: no-store. The former names the scheme a Bearer-token client should retry with (RFC 7235/6750); the latter keeps a shared or browser cache from ever storing an authentication failure — or, worse, a response accidentally produced for someone else's stale session.

Session visibility and lifecycle

ListUserSessions(ctx, userID) returns every session belonging to a user — CreatedAt, LastSeenAt, AuthenticatedAt, Method, IP, UserAgent, all populated — for building a "where you're signed in" screen: render each entry ("Chrome, last active 2 hours ago, 203.0.113.4") and let the user call RevokeSession on anything they don't recognize.

TokenHash is always empty on what ListUserSessions returns, even though the underlying store does store and return one — a device-management screen has no legitimate reason to see even a hash of a bearer credential, so this method blanks it before the slice ever leaves the package. This is the property to test first if you're implementing your own SessionStore.ListUserSessions: the store method itself returns TokenHash exactly as stored (the same as GetSessionByTokenHash); sulis.Sulis.ListUserSessions is what strips it.

RefreshSession(ctx, session) returns (*Session, string, error) and rotates a session's token: a new ID, a new raw token, and ExpiresAt extended from now, while UserID, Method, AuthenticatedAt, CreatedAt, IP, UserAgent, and Metadata all carry forward unchanged. AuthenticatedAt is preserved, not refreshed — a token rotation is not a new authentication proof, and resetting it would silently extend how long a stolen-but-since-rotated session passes RequireRecentAuth. Call it periodically (e.g. once per day of active use) to bound how long any one bearer token stays valid, independent of SessionDuration.

The old session row is deleted FIRST, and minting the new one only happens if that succeeds. This is a fail-closed liveness check on session, not an ordering nicety: without it, a caller holding a stale *Session from before a revocation (RevokeSession, RevokeAllSessions, or an eviction via the "where you're signed in" screen above) could call RefreshSession and mint a working replacement anyway — un-evicting themselves. RefreshSession also reloads the user and checks account status and RequireVerifiedEmail before minting, so neither a disabled or locked account's stale *Session nor an unverified account's signup session can be refreshed into a live one — the former even in the edge case where the old row happened to survive whatever disabled the account, the latter because a refresh mints a session and Register's exemption from the verified-email gate was meant to last that one session, not to be renewable forever. All three checks run after the delete succeeds, so an attempt that fails any of them still burns the caller's old session on its way to the error: an unverified user who calls RefreshSession is signed out and must verify and sign in again, which is the same trade the disabled case makes and the safe direction for both. The cost is a small crash window: a crash between the delete and the create logs the caller out rather than leaving two valid tokens briefly outstanding — the safe direction to fail in.

IP/UserAgent are carried forward from the stale *Session you pass in, not re-derivedRefreshSession takes no RequestInfo. A session refreshed repeatedly over a long life can therefore show the IP/UserAgent it was originally issued with in a ListUserSessions listing, even while LastSeenAt looks current from ValidateSession's own touch.

There is no facade method for "sign out everywhere else, keeping this one": compose it from what's here — ListUserSessions to find the other IDs, RevokeSession per ID. SessionStore.DeleteUserSessionsExcept(ctx, userID, keepSessionID) exists as a single-query store-level primitive for an application implementing its own store that wants to skip the per-session loop; see Store Contracts.

Step-up authentication

Every session carries AuthenticatedAt (when its owning credential was last proven) and Method (which credential proved it), both stamped at issuance by Register, Login/RedeemMagicLink (via completeFirstFactor), CompleteTwoFactor (AuthMethodTwoFactor, regardless of which method passed the first factor), and IssueSession/IssueSessionUnchecked.

RequireRecentAuth(ctx, session, maxAge) returns ErrReauthRequired if session.AuthenticatedAt is older than maxAge, and nil otherwise. It's a pure check against a *Session you already have (typically whatever ValidateSession just returned) — no store round trip. A session issued before this field existed reads back with a zero AuthenticatedAt, which is always older than any maxAge: such a session fails closed, never treated as fresh.

Gate these operations behind RequireRecentAuth, not a bare session — each changes something an attacker who merely stole a cookie should not be able to change:

  • Enrolling or replacing a TOTP factor (totp.Service.Enroll, ReplaceEnrollment)
  • Disabling two-factor authentication
  • Adding or removing a passkey
  • Changing email (ChangeEmail)
  • Regenerating recovery codes
session, user, err := auth.ValidateSession(ctx, token)
if err != nil {
    return err
}
if err := auth.RequireRecentAuth(ctx, session, 15*time.Minute); err != nil {
    // Prompt for the password again, then call ReAuthenticate, before
    // letting the request through to totp.Enroll / passkey removal / etc.
    return err
}

ReAuthenticate(ctx, session, password, requestInfo) is the write side: it verifies password for session's owning user and, on success, stamps session.AuthenticatedAt with the current time — both on the stored session and on the *Session you passed in, so you don't need to reload it. It mints no new session and does not rotate the token: session.ID and its token hash are unchanged, so the user's existing session (and its raw token, if they still hold it) keeps working exactly as before, just freshly re-authenticated. Like VerifyPassword, it's rate-limited on both the account ("password:"+email, the same budget Login/VerifyPassword/ChangePassword share) and IP dimensions, and equalizes timing for a passwordless account via the same dummy-hash path. Returns ErrInvalidCredentials for a passwordless account or a wrong password, and in neither case is AuthenticatedAt touched. It also returns ErrAccountDisabled/ErrAccountLocked via the same account-status check every other issuance path applies (see Account disable and lockout) — checked right after loading the user, before spending an Argon2 verification on a call that cannot succeed either way. A successful call is also a real password comparison against a real stored hash, so it participates in the same transparent hash upgrade Login/VerifyPassword do (see Login and VerifyPassword) — best-effort, and with no effect on AuthenticatedAt or the returned error either way.

Account disable and lockout

DisableUser(ctx, userID, reason) takes an account out of service immediately: it stamps User.DisabledAt and records reason (caller-supplied context — sulis never inspects it), then revokes every session the account currently holds. EnableUser(ctx, userID) reverses it — DisabledAt/DisabledReason are cleared and the account authenticates normally again. Both return ErrUserNotFound for an unknown user.

Disabling invalidates sessions already issued, not just future logins. ValidateSession checks DisabledAt on every call, independent of whether DisableUser's own session revocation happens to succeed — so even a store that failed to delete a session, or a session created after the disable took effect but somehow missed by the delete, still dies on its very next use. This is the check that matters most: without it, disabling would leave every session an attacker (or a since-fired employee, or a compromised account) already holds working for the rest of its natural lifetime.

Every session-issuance path checks account status: VerifyPassword (and so Login), completeFirstFactor (the choke point shared by Login and RedeemMagicLink), IssueSession/IssueSessionUnchecked, and CompleteTwoFactor. Register is exempt by nature — a freshly created account cannot already be disabled or locked. ReAuthenticate checks it too, even though it issues no session: without that check it could still refresh AuthenticatedAt for a disabled or locked account's already-held session (see Step-up authentication).

VerifyPassword checks status only after the password has verified. An unauthenticated caller who has not proven the password must not be able to use a distinct ErrAccountDisabled/ErrAccountLocked to learn that an account exists and is disabled or locked — that is exactly the kind of oracle the dummy-hash timing equalization above already closes for existence and password-presence; checking status any earlier would reopen an equivalent one for account status. A wrong password against a disabled account returns the ordinary ErrInvalidCredentials, same as any other wrong password.

LockedUntil is the automatic-lockout counterpart to DisabledAt (see below) and is checked the same way — after the password verifies — but ValidateSession does not check it. A temporary lockout throttles new authentication attempts; it does not retroactively invalidate a session issued before the lockout began, since the account owner already proved their identity once to get that session and an automatic, attacker-triggerable mechanism killing it too would make the denial-of-service risk below worse, not better.

Automatic lockout is off by default. WithFailureLockout(threshold, baseBackoff, maxBackoff) enables it: after threshold consecutive wrong passwords, VerifyPassword sets LockedUntil to baseBackoff past the triggering failure, and every further wrong password while still locked pushes it out again, doubling up to maxBackoff. It clears itself — both LockedUntil and the failure count — the next time a correct password verifies outside the window; there is no explicit unlock call. It is deliberately opt-in: this mechanism locks out the legitimate owner exactly as effectively as it locks out an attacker, so anyone who merely knows (or guesses) an email address can weaponize it as a denial-of-service against that account. The rate limiter (on by default; see Operational requirements) is the first line of defense against guessing and does not share this failure mode, since it throttles the guesser without touching the account's own ability to log in once its window passes. Enable automatic lockout only if your threat model needs an escalating response beyond rate limiting, and prefer a long baseBackoff/maxBackoff over a short one.

DisableUser/EnableUser are a separate, operator-initiated mechanism from automatic lockout — disabling doesn't touch LockedUntil/FailedLoginAttempts, and enabling doesn't forgive an in-progress automatic lockout the operator may not know about.

A successful password change or reset also clears an active lockout. ChangePassword, ResetPassword, and SetInitialPassword all clear FailedLoginAttempts/LockedUntil on success, the same as a correct login password does. This matters because the two mechanisms compose badly otherwise: with WithFailureLockout enabled, an attacker can lock a victim's account with nothing but repeated wrong guesses, and the victim's own recovery path — proving control via an out-of-band reset token, which is at least as strong an identity proof as a login password — would otherwise leave them waiting out maxBackoff anyway, defeating the point of being able to reset a password at all. DisabledAt/DisabledReason are never touched by a password change or reset — disabling is an operator's decision, and only EnableUser reverses it; no proof of the password lifts it.

Password Reset

CreatePasswordResetToken(ctx, email, requestInfo) creates a password-reset token and returns the raw token so the caller can deliver it out-of-band. If no account exists for email, it returns ("", nil) rather than ErrUserNotFound — like Login/VerifyPassword, the response can't be used to tell a registered address from an unregistered one. The unknown-address path still generates and hashes a token of the same size the known-address path would create, then discards it, so the two paths can't be distinguished by the work they perform either; see Operational requirements for the one residual asymmetry (a store write) this can't equalize away.

CreatePasswordResetTokenStrict(ctx, email, requestInfo) is the same call with the safe default turned off: it returns ErrUserNotFound verbatim for an unknown address. It exists for admin tooling that has already authenticated an operator and genuinely needs to know whether the address is registered — never wire it to a public-facing endpoint, or you reopen the enumeration oracle CreatePasswordResetToken exists to close.

ResetPassword(ctx, rawToken, newPassword) checks the password policy first — length and the configured PasswordChecker, see Password quality — so a policy failure doesn't burn the token, then atomically consumes it (hash + purpose, single-use), loads the user, and updates the password hash. It returns ErrTokenInvalid for an unknown or wrong-purpose token and ErrTokenExpired for an expired one. A replay's error depends on timing: redeeming the same still-live token twice (e.g. a concurrent racing request) returns ErrTokenAlreadyUsed for the loser; redeeming it again after a successful reset returns ErrTokenInvalid instead, because a successful reset purges the user's outstanding password-reset tokens, so the replay finds nothing to consume rather than an already-used row.

By default, both ChangePassword and ResetPassword revoke every session belonging to the user and delete any other outstanding password-reset tokens for that user — see Operational requirements. Every path that sets a password (ChangePassword, ResetPassword, and SetInitialPassword) also purges the account's pending two-factor tokens and its outstanding magic links, regardless of that setting: a pending 2FA login was minted against the old password's first factor, and a magic link is a mailbox-derived credential a password rotation is often being performed to escape. See Changing an email address for the other half of the same recovery.

CreateMagicLinkToken(ctx, email, requestInfo) (token, bindingNonce string, err error) creates a magic-link token and returns the raw token for delivery. If no user exists for the email yet, no user row is created at this point — only the token, carrying the email — so that requesting magic links for arbitrary addresses can't be used to flood the user store. The user is created lazily at redemption. This also means CreateMagicLinkToken never returns ErrUserNotFound — unlike CreatePasswordResetTokenStrict, which does; CreatePasswordResetToken itself, like CreateMagicLinkToken, never leaks that distinction to a public caller.

The token expires after MagicLinkDuration (default 15m), not TokenDuration (which now governs password-reset tokens only — see WithMagicLinkDuration). A magic link is a full credential delivered in cleartext over email, where it can sit in an inbox, get forwarded, get scanned by a corporate mail-security appliance, or get prefetched by a client before a human ever clicks it — all things a password-reset link, typed by a human who just requested it, is far less exposed to. Fifteen minutes is deliberately short; raise it only if your delivery pipeline (queueing, retries) genuinely needs the slack.

RedeemMagicLink(ctx, rawToken, bindingNonce string, requestInfo) atomically consumes the token, checks bindingNonce (see "Binding a magic link to its requester" below), loads the user (creating a passwordless one now if the token predates the account), stamps EmailVerifiedAt (redeeming a magic link proves control of the mailbox), and then returns a *LoginResult on exactly the same terms as Login.

A magic link is a full first factor, not a shortcut past 2FA. Proving control of the mailbox is equivalent to knowing the password, so if the account has a second factor enrolled the result carries a PendingToken and no session. Verification is stamped before that branch, so a 2FA-enabled user with an unverified address can still verify it by following a magic link.

Migration note: both signatures changed — CreateMagicLinkToken now returns a third value (bindingNonce) and RedeemMagicLink now takes it as a new second parameter — so every existing call site needs updating to compile, not just to behave correctly. Two defaults changed with them: the magic-link TTL dropped from the shared TokenDuration (1h) to its own MagicLinkDuration (15m) — pass WithMagicLinkDuration(time.Hour) to keep the old window — and binding is on by default, so a deployment that doesn't wire up the nonce cookie (see below) will see every redemption rejected with ErrTokenInvalid unless it either does the wiring or passes WithMagicLinkBinding(false).

By default (MagicLinkBinding, default true), CreateMagicLinkToken also generates a random binding nonce and returns it alongside the token; the created Token stores only the nonce's SHA-256 hash (Token.NonceHash), never the plaintext, exactly as TokenHash never stores the raw token. Wire it up like this:

  1. At issuance, set bindingNonce as a short-lived, HttpOnly cookie on the response to the request that triggered CreateMagicLinkTokennot in the emailed link itself, which would defeat the entire point by traveling with the token to wherever the link ends up.
  2. At redemption, read that cookie back and pass its value as RedeemMagicLink's bindingNonce argument, alongside the token recovered from the link's query string.

Because the nonce lives only in a cookie scoped to the browser that requested the link, a copy of the link forwarded to someone else — or opened on a different device, such as a phone that didn't request it — arrives without the matching cookie. RedeemMagicLink then rejects it with ErrTokenInvalid, even though the token itself is still valid, unused, and unexpired. That is the property this task exists for: a forwarded magic link cannot sign anyone in. A missing and a wrong nonce fail identically, so neither leaks which half was the problem, and the comparison is constant-time (crypto/subtle.ConstantTimeCompare over the hashes). The check runs after the token is atomically consumed, so a wrong nonce still burns the token — the same fail-safe-forward direction ResetPassword/RedeemMagicLink already apply to an expired token (see consumeToken's own doc comment) — which is why an attacker holding a stolen token but not its cookie gets exactly one chance to guess right, not unlimited retries against a token that stays live.

WithMagicLinkBinding(false) turns this off entirely: CreateMagicLinkToken stops generating a nonce (bindingNonce comes back ""), the stored token carries no NonceHash, and RedeemMagicLink accepts any bindingNonce value, including "". This is a real, sometimes-legitimate trade-off — mail is routinely read on a different device than the one that requested it (desktop request, phone inbox) — but it also means a link forwarded to someone else, or fetched early by an automated mail scanner, signs that other party or scanner in instead of the intended recipient. Turn it off deliberately, with that risk in mind; it is not the default for a reason.

Prefetch hazard, independent of binding: some corporate mail gateways and antivirus scanners follow every link in an email to check where it leads, before a human ever opens the message — which, against a single-use token, consumes it. Binding limits the blast radius of that (the scanner's redemption fails without the requester's cookie, so at most it burns the token rather than signing the scanner in) but does not stop the token from being wasted. Mitigate this at the application layer with an explicit confirmation step: land the clicked link on an interstitial page that asks the user to click a button before the token is actually redeemed, rather than redeeming on the bare GET. A scanner that only follows links, and never clicks a button on the page it lands on, then never touches the token at all.

Two-Factor

Two-factor authentication is a pending-login token sandwiched between a verified first factor and a verified second factor. sulis doesn't implement any second factor itself — pair it with totp, recovery, or passkey below — it only issues and redeems the short-lived pending token that stands in for "first factor passed, second factor pending."

Flow: VerifyPassword → your app checks whether the user has 2FA enabled → CreateTwoFactorToken → your app independently verifies the second factor (TOTP, recovery code, or passkey) → CompleteTwoFactor(ctx, userID, rawToken, requestInfo), which returns a *LoginResult on the same terms as Login. No session exists until CompleteTwoFactor succeeds; the pending token is single-use, purpose-scoped (rejected by ResetPassword, RedeemMagicLink, and VerifyEmail), and expires after TwoFactorTokenDuration (default 5 minutes).

By default, CreateTwoFactorToken returns ErrEmailNotVerified for an unverified account, failing before your app ever prompts for a second factor. CompleteTwoFactor re-checks the same condition as defense in depth (the token is consumed either way), against the account's current verification state rather than its state when the token was minted.

CompleteTwoFactor takes userID as an explicit argument and rejects the token with ErrTokenInvalid if it wasn't minted for that user (consuming the token either way, so a mismatched attempt also burns it). Your app must carry the userID obtained from VerifyPassword through its own server-side state across the two requests — e.g. keyed by the pending token, or in a short-lived server session — and pass that value to CompleteTwoFactor. Never accept a client-supplied userID for this call: if the second-factor request's userID came from the client instead, an attacker who can produce a valid second factor for their own account (their own TOTP code, their own passkey) could pair it with someone else's pending token and pass someone else's userID, since sulis only checks that the token and the userID match each other, not that the caller is who they claim.

ri := sulis.RequestInfo{IP: ip, UserAgent: ua}

user, err := auth.VerifyPassword(ctx, email, password, ri)
if err != nil {
    return err // ErrInvalidCredentials or ErrRateLimited
}

if !userHasTwoFactorEnabled(user) {
    // This app determines 2FA status itself rather than through Login's
    // SecondFactorChecker, so sulis has no Authentication to offer here —
    // IssueSessionUnchecked is the caller-vouches-for-it primitive for
    // exactly that case. The raw session token comes back beside the
    // *Session, never on it (Session has no Token field).
    session, sessionToken, err := auth.IssueSessionUnchecked(ctx, user.ID, sulis.AuthMethodPassword)
    return finish(user, session, sessionToken, err)
}

// First factor passed; hold a pending token instead of a session.
pending, err := auth.CreateTwoFactorToken(ctx, user.ID)
if err != nil {
    return err
}
// Return `pending` to the client (e.g. in a short-lived, httpOnly value) and
// prompt for a second factor. Persist user.ID server-side (e.g. keyed by
// `pending`) so the follow-up request doesn't have to trust the client for it.

// On the follow-up request:
if err := totpSvc.Validate(ctx, user.ID, submittedCode); err != nil {
    // totp.ErrTOTPInvalid (wrong code), totp.ErrTOTPNotEnrolled,
    // totp.ErrTOTPNotVerified, totp.ErrTOTPReplayed, or
    // totp.ErrTOTPRateLimited; consider falling back to a recovery code.
    remaining, rerr := recoverySvc.Consume(ctx, user.ID, submittedRecoveryCode)
    if rerr != nil {
        return rerr // recovery.ErrCodeInvalid or recovery.ErrCodeRateLimited
    }
    // A recovery code is a full bypass of every other factor. Your app,
    // not recovery, must now revoke the user's other sessions, and once
    // remaining reaches 0, push them to re-enroll a real second factor —
    // see "Recovery codes and the 2FA lifecycle" under the recovery
    // subpackage below.
    _ = remaining
}

// user.ID here comes from server-side state established above, never from
// the client's request. CompleteTwoFactor returns a *LoginResult, the same
// shape Login returns — the session and its raw token are fields on it.
res, err := auth.CompleteTwoFactor(ctx, user.ID, pending, ri)
if err != nil {
    return err
}
return finish(res.User, res.Session, res.SessionToken, nil)
Email Verification

CreateEmailVerificationToken(ctx, userID) issues a single-use token proving control of the user's registered address (default TTL 24h), bound to that address: if the user's email changes before redemption, VerifyEmail rejects the stale token with ErrTokenInvalid rather than verifying the new address. VerifyEmail(ctx, rawToken) consumes it and stamps User.EmailVerifiedAt. Verification is idempotent — once set, EmailVerifiedAt is never overwritten by a later verification (e.g. a second magic-link redemption keeps the original timestamp). By default, an unverified EmailVerifiedAt also blocks new sessions elsewhere in the library — see Operational requirements for RequireVerifiedEmail.

The first time an account with a password gets verified (via either VerifyEmail or a redeemed magic link), all of that user's sessions are revoked unconditionally — regardless of RevokeSessionsOnPasswordChange. This closes a residual account-takeover window: an attacker who registered the victim's email with their own password before the victim ever proved mailbox control could otherwise keep a live session through the victim's later verification. Applications should additionally prompt for a password reset on this path, since the attacker's chosen password itself remains valid until changed.

Changing an email address

An email address is an identity, and on most products it is also the reset channel — so changing one is an account-takeover primitive if it is done in a single step. sulis splits it in two.

ChangeEmail(ctx, userID, newEmail) stages the new address and returns a raw, single-use token to deliver to it. User.Email and User.EmailVerifiedAt are untouched; only User.PendingEmail is set. It returns ErrInvalidEmail for a malformed address and ErrUserAlreadyExists if newEmail is already the live address of any account, including this one. Staging a second address supersedes the first, invalidating the earlier token. The token expires after EmailVerificationTokenDuration (default 24h).

ConfirmEmailChange(ctx, rawToken) consumes the token and makes the staged address live: Email is swapped in from PendingEmail, PendingEmail is cleared, and EmailVerifiedAt is re-stamped with a fresh timestamp — the old stamp proved control of the old address, not this one. The swap also revokes every session on the account and purges its outstanding password-reset, two-factor, and magic-link tokens, since all three were minted against (or reachable through) the identity that just changed. The magic-link purge is the one that closes the takeover rather than tidying up after it: a link the attacker requested while they still had the mailbox is stored against the account's user ID with no record of the address it went to, so RedeemMagicLink would happily mint a session on the recovered account after the swap. A mailbox-compromise recovery has to burn every outstanding mailbox-derived credential, not most of them. (ResetPassword/ChangePassword purge magic links for the same reason.) It returns ErrTokenInvalid if the token is unknown, expired, already used, of the wrong purpose, or bound to an address that is no longer the account's PendingEmail (a later ChangeEmail superseded it), and ErrUserAlreadyExists if another account claimed the staged address in the meantime.

sulis sends no mail, and two of the notifications are not optional. Deliver the token to the new address — that is what proves the requester can receive there. But you must also notify the old address, twice: once when a change is staged, and once when it is confirmed. That notification goes to an address the attacker does not control, and it is the only way a victim catches a takeover — the first while the pending change can still be undone, the second at least in time to start recovery.

Gate ChangeEmail behind RequireRecentAuth, not a bare session.

Security events

Every security-relevant decision the root package makes can be reported to an EventSink — failed logins, limiter trips, second-factor demands, session issuance and expiry, password rehashes, magic-link rejections, account disables. Nothing is emitted by default.

Wiring it up is one line if you already have a *slog.Logger:

auth, err := sulis.New(users, sessions, tokens, factors,
    sulis.WithEventSink(sulis.NewSlogSink(logger)))

Or implement the interface yourself:

type EventSink interface {
    Emit(ctx context.Context, e sulis.Event)
}

Emit returns nothing, on purpose: a sink cannot fail a flow. There is no error to propagate and none to ignore. Emissions happen after the decision they report, never before, so an event records what already happened rather than announcing what is about to. A sink that panics is contained (recovered and dropped) rather than allowed to unwind the flow — an observability hook must not be able to deny authentication to everybody — but don't build on that: Emit runs on the caller's goroutine and inside the caller's latency budget, so hand the event to a channel, a logger, or a buffer and return.

With no sink configured the cost is one nil check per decision point: nothing is allocated, no timestamp is read. That is a tested claim rather than an aspiration — Event.Metadata is built inside the emission helper after the nil check, from variadic labels, precisely because a map built at the call site would be allocated whether or not anybody was listening, and TestNilSinkPathAllocatesNothing measures it.

What an event contains — and what it never contains
type Event struct {
    Kind        EventKind
    UserID      string                 // when known
    SessionID   string                 // when relevant
    RequestInfo RequestInfo            // what you passed to the flow
    At          time.Time
    Metadata    map[MetadataKey]string // reason / method / scope / dimension
}

No event ever carries credential material. There is deliberately no field that could hold one — no token, no password, no hash, no nonce. Beyond that, the package never copies any caller-supplied string into an event except the RequestInfo you explicitly passed. In particular an event never carries:

  • a raw password, session token, reset/magic-link/two-factor/email token, or magic-link binding nonce;
  • a stored password hash or session token hash;
  • the submitted email address — people type passwords into the email field, and an event taxonomy that copies caller input is one bad day away from being a credential log;
  • the operator-supplied reason you passed to DisableUser, for the same reason.

Accounts are identified by UserID and sessions by SessionID: opaque identifiers sulis generated, neither of which authenticates anything on its own. The rule is enforced by test, not only by convention — TestNoEventCarriesSecretMaterial drives every emitting flow and scans every field of every emitted event against every secret those flows were fed, and a companion test proves the scanner catches a planted one.

Metadata has a closed key set — MetaReason, MetaMethod, MetaScope, MetaDimension — whose values are short fixed labels chosen by sulis (the Reason*, Scope*, and Dimension* constants), never error text and never caller input.

The taxonomy
Kind Emitted when Metadata
account.registered Register created an account
login.succeeded a password verified (not "a session exists" — a second factor may follow) method
login.failed an authentication attempt was refused reason, method
password.changed ChangePassword succeeded
password.set SetInitialPassword succeeded
password.reset_requested a reset token was issued (unknown addresses emit nothing)
password.reset ResetPassword succeeded
password.rehashed a stored hash was upgraded on successful verification
password.rehash_failed that upgrade was attempted and lost reason
password.legacy_form_matched a password matched only via the pre-NFKC fallback
twofactor.demanded a verified first factor earned a pending token, not a session method
twofactor.completed CompleteTwoFactor accepted the second factor
twofactor.failed CompleteTwoFactor refused reason
session.issued a session row was created, by any path method
session.revoked one or all of an account's sessions were deleted scope
session.refreshed RefreshSession rotated a token method
session.expired ValidateSession rejected a session past ExpiresAt reason
session.idle_expired ValidateSession rejected a session past IdleExpiresAt reason
email.change_staged ChangeEmail staged an address and issued a token
email.change_confirmed ConfirmEmailChange made a staged address live
email.verified an address was verified for the first time
magiclink.created CreateMagicLinkToken issued a link
magiclink.redeemed a link's token and binding nonce both checked out
magiclink.rejected RedeemMagicLink refused reason
ratelimit.tripped the configured Limiter denied a key scope, dimension
account.disabled DisableUser stamped the account
account.enabled EnableUser cleared it
account.locked automatic lockout set or extended a deadline
account.lockout_cleared a correct password cleared stale lockout bookkeeping
reauth.succeeded ReAuthenticate refreshed the step-up clock
reauth.failed ReAuthenticate refused reason
csrf.rejected (*Sulis).RequireCSRFToken refused a request reason
sameorigin.rejected (*Sulis).RequireSameOrigin refused a request reason

A few of these repay watching directly:

  • password.legacy_form_matched is what makes retiring the pre-NFKC verification fallback answerable. When it stops appearing for your deployment, every account has migrated and the fallback can go — without it, the fallback would have to stay forever on the grounds that nobody can prove it's unused.
  • password.rehash_failed is the only trace a lost hash upgrade leaves. The failure is deliberately swallowed so it can't fail an otherwise-correct login (see Rehash on login), which means without this event a store quietly refusing every upgrade would be invisible.
  • magiclink.rejected with reason=binding_mismatch means a genuine token was presented by a browser other than the one that asked for the link: forwarded, prefetched, or stolen.
  • ratelimit.tripped distinguishes dimension=account from dimension=ip. One account being guessed and one host spraying many accounts are different incidents, which is why the two keys exist.
  • login.failed with reason=factor_check_failed means your SecondFactorChecker errored and sulis failed closed. Correct behaviour, invisible without this.
Middleware and events

RequireCSRFToken and RequireSameOrigin exist both as package-level functions and as methods on *Sulis. The behaviour is identical; only the methods can emit, because a free function has no configured sink to emit to. Use the methods if you want rejections observable:

mux.Handle("/api/", auth.RequireSameOrigin(origins)(auth.RequireCSRFToken(handler)))

These are the one place sulis derives a RequestInfo itself, from r.RemoteAddr and User-Agent. That address is the transport peer: behind a reverse proxy it is the proxy, not the client. sulis does not read X-Forwarded-For or any other hop header, because which of them to trust is a deployment fact no library can know and guessing wrong means trusting an attacker-supplied address.

Scope

This taxonomy (EventKind/Event/EventSink/WithEventSink) covers the root package only. totp and passkey have their own services and stores and do not emit events. recovery is the one exception: it ships its own, independent EventKind/Event/EventSink/WithEventSink — see "Recovery codes and the 2FA lifecycle" under the recovery subpackage below for its three-kind taxonomy and for why it can't just reuse this one. Authenticate's 401 is not its own kind either — the decisions behind it (session.expired, session.idle_expired) already emit, and a kind for "a request arrived with no valid token" would make this a request log rather than a security-decision log.

Subpackages

totp

totp implements RFC 6238 TOTP without external dependencies. NewService(store, issuer, opts...) returns an error if the resolved config is out of bounds (empty/:-containing issuer, digits outside 6-8, period outside 15-300s, skew above 4, or secret size below 16 bytes).

It supports enrollment (Enroll, pending until ConfirmEnrollment), explicit replacement (ReplaceEnrollment), validation (Validate), unenrollment (Unenroll), configurable HMAC algorithms (SHA1, SHA256, SHA512), configurable digit/period/skew settings, and otpauth:// URI generation for authenticator apps.

Validate(ctx, userID, code) error returns nil if and only if the code is valid; every rejection is a distinct, non-nil error, so a caller that only checks err != nil before granting access rejects a wrong code correctly. A wrong code returns ErrTOTPInvalid, distinguishable via errors.Is from the other rejections below.

Validate and ConfirmEnrollment enforce replay protection: each accepted code's time-step counter is persisted as Credential.LastUsedCounter, and a code is only accepted if its counter is strictly greater than the last one accepted for that credential. Reusing a code, or presenting an older one after a newer counter has already been accepted, returns ErrTOTPReplayed.

A stray Enroll call cannot clobber a working factor. totp.Store keeps a user's active (verified) credential and a pending (unverified) enrollment as two separate slots. Enroll refuses with ErrTOTPAlreadyEnrolled if the user already has an active credential — a double-submitted form, a CSRF'd POST, or a retried request must not be able to silently replace a confirmed second factor with an unconfirmed one. ReplaceEnrollment(ctx, userID, accountName) (secret, uri string, err error) is the explicit path for superseding an active factor on purpose: it always succeeds, and the old factor stays active — Validate keeps accepting its codes — until ConfirmEnrollment verifies a code for the new secret and promotes it. A pending enrollment sitting unconfirmed never affects Validate against the active credential.

ConfirmEnrollment promotes the pending enrollment to active atomically (Store.ConfirmEnrollment), carrying LastUsedCounter forward monotonically: if an active credential already existed (a replacement is being confirmed), the promoted credential's counter is never set lower than what the replaced factor had already recorded, so swapping factors can't roll a user's replay-protection clock backward.

A confirm retry looks like ErrTOTPNotEnrolled, not success. Once ConfirmEnrollment promotes a pending enrollment, that slot is consumed exactly once. A double-submitted confirmation form, or a dropped HTTP response after the server already committed the promotion, means a second call with the same code finds nothing pending and also returns ErrTOTPNotEnrolled — even though the user is enrolled and the first call's factor is active and working. Don't render that error as "you are not enrolled"; check current status (e.g. via your own record of enrollment, or by attempting Validate) before reacting to a confirm retry.

totp.WithLimiter configures a rate limiter (structurally identical to the root Limiter interface, declared separately so this package has no dependency on the root module) consulted by both Validate and ConfirmEnrollment, keyed by "totp:"+userID. A denied check returns ErrTOTPRateLimited. This is not optional in production: a 6-digit code is a 10^6 space, brute-forceable without a limiter.

Enrollment changes a security-relevant setting for the account: gate Enroll/ReplaceEnrollment behind RequireRecentAuth — see Step-up authentication — rather than a bare session.

The package depends on a consumer-owned totp.Store for saving and loading TOTP credentials — see Store Contracts below for the active/pending separation and its atomicity requirements.

Encrypting stored secrets

By default, Credential.Secret reaches your store as base32 plaintext. Unlike a password hash, a TOTP secret has no work factor standing between a leak and its use: whoever reads it can generate valid codes for that account indefinitely, silently, with no way to detect or revoke the compromise short of re-enrollment. This is fine for a throwaway store in tests, and not something you should ship to production unencrypted.

totp.WithEncryptor(e Encryptor) fixes this from inside the package, so the protection does not depend on your store implementation at all:

enc, err := totp.NewAESEncryptor(key) // key: 32 bytes, AES-256
svc, err := totp.NewService(store, "MyApp", totp.WithEncryptor(enc))

Service encrypts a secret before every write (Enroll, ReplaceEnrollment, Validate's replay-counter bump) and decrypts it immediately after every read (ConfirmEnrollment, Validate) — entirely inside this package. Your totp.Store implementation never receives, persists, or reads back a usable secret; Credential.Secret is still just a string either way, so no store contract, schema, or column type needs to know encryption exists. Nothing in Store Contracts changes.

NewAESEncryptor implements Encryptor with AES-256-GCM: a random 96-bit nonce on every Encrypt call, and a key-ID fingerprint (derived from the key itself, not assigned or positional) prefixed onto the ciphertext so Decrypt knows which key produced it. Key rotation:

// Today: everything is encrypted under keyA.
enc, _ := totp.NewAESEncryptor(keyA)

// Rotating in keyB: it becomes current for all new Encrypt calls; keyA is
// kept only so ciphertext already written under it keeps decrypting.
enc, _ := totp.NewAESEncryptor(keyB, keyA)

There is no in-place "re-encrypt everything" step — like the rehash-on-login upgrade path for Argon2 parameters, a secret only actually moves onto the new key the next time Service writes it (a fresh enrollment, or Validate's counter bump), not immediately on rotation. Once you're confident nothing still needs a retired key, drop it from the rotated list.

Decryption fails closed. A wrong key, a truncated ciphertext, an unrecognized key-ID, or a tampered payload all return a non-nil error from Decrypt — never a plausible-looking but wrong plaintext. Service propagates that error distinctly from ErrTOTPInvalid/ErrTOTPNotEnrolled: a decrypt failure means the enrollment genuinely exists but this instance's Encryptor cannot recover its secret (most likely a missing rotated key), which is a different problem from a wrong code or no enrollment at all and should be alerted on differently.

Turning on WithEncryptor for the first time on an existing deployment does not silently break, and does not silently do nothing. Rows enrolled before an Encryptor was configured are still base32 plaintext; AESEncryptor.Decrypt never mistakes that for its own ciphertext — depending on the stored secret's length, it fails either at base64 decoding or (for the default 20-byte secret size, whose base32 alphabet happens to also be valid base64) because the bytes it reads as a key-ID fingerprint were never registered by any configured key. Either way, the next ConfirmEnrollment or Validate call against that enrollment fails closed with a distinct, non-nil error rather than reading the row as plaintext or as a wrong code. There is no automatic migration: the recovery path for a pre-Encryptor enrollment is re-enrollment (ReplaceEnrollment), the same as recovering from a lost authenticator.

Bring your own Encryptor (a KMS or HSM-backed one, for instance) by implementing the two-method interface directly; AESEncryptor is provided because it needs nothing beyond the standard library, not because it's the only option.

passkey

User verification is required by default. NewService sets UserVerification: required on the relying-party config and on both login ceremonies. This matters because go-webauthn only checks the UV flag in the authenticator data when the ceremony's session data says required — leaving it unset means a presence-only tap (no PIN, no biometric) is accepted, which reduces a passwordless passkey from two factors to bare possession of an unlocked device.

Pass passkey.WithUserVerification(protocol.VerificationDiscouraged) only when the passkey is a second factor behind a verified password.

Registration requests a discoverable credential by default. NewService also sets ResidentKey: required (plus the legacy RequireResidentKey boolean, for authenticators that predate the residentKey enum) on the relying-party config, and BeginRegistration asks for the credProps extension. Without this, BeginDiscoverableLogin (usernameless login) only works when an authenticator happens to create a discoverable credential anyway, and the fallback to identified login trains users back onto typing a username. Pass passkey.WithResidentKey(protocol.ResidentKeyRequirementPreferred) (or ...Discouraged) only if you don't offer usernameless login and every caller of BeginLogin always supplies a username first.

FinishRegistration records what the client actually reported, not just what was requested: Credential.Discoverable is populated from the client's credProps.rk extension output. This is a client-reported (unsigned) signal, not a cryptographic property of the credential — an older browser or authenticator may omit credProps entirely even for a credential that is, in fact, discoverable, in which case Discoverable is recorded as false (see the field's GoDoc for the full caveat).

passkey wraps github.com/go-webauthn/webauthn to provide higher-level passkey registration and login helpers. It manages begin/finish WebAuthn ceremonies, persists credentials through a consumer-owned passkey.Store, and persists transient ceremony state through a consumer-owned passkey.ChallengeStore.

Besides the identified BeginRegistration/FinishRegistration pair (which requires the caller to already know the user), passkey supports discoverable ("usernameless") login: BeginDiscoverableLogin(ctx) returns the assertion options plus a ceremonyID that the caller must round-trip to FinishDiscoverableLogin(ctx, ceremonyID, r). The user is resolved from the credential's stored owner (via the authenticator's user handle), not supplied by the caller.

BeginLogin(ctx, user) (the identified, non-discoverable login path) likewise returns (*protocol.CredentialAssertion, string, error) — an assertion plus a ceremonyID — and FinishLogin(ctx, user, ceremonyID, r) takes that ceremony ID back. The challenge is keyed per-ceremony rather than per-user so that a second login ceremony for the same user (e.g. started from another device) cannot clobber the first device's in-flight challenge.

Both finish paths reject a credential flagged with a sign-count anomaly by returning ErrCloneWarning — treat this as a signal of possible credential cloning, not a routine auth failure. On success, FinishLogin/FinishDiscoverableLogin persist the updated sign count, backup state, and LastUsedAt, then return the stored Credential; passkey does not create a sulis session for you. A finished passkey ceremony is verified entirely inside passkey, so sulis has no Authentication proof to offer for it — call IssueSessionUnchecked(ctx, userID, sulis.AuthMethodPasskey) (directly or via the two-factor flow) after a successful finish.

Credential metadata for a management UI. Besides the fields already covered above, Credential carries Name (caller-supplied display metadata — passkey never generates or validates it; set it via Store.RenameCredential), Transports (the client's reported transport list — "usb", "nfc", "ble", "hybrid", "internal" — from the registration response, persisted once at registration and not re-verified afterward), BackupEligible (whether the authenticator is capable of being backed up/synced, a verified property derived from the signed authenticator data), BackupState (whether the credential is currently backed up — unlike BackupEligible this can flip over the credential's lifetime, so it is re-derived and re-persisted on every successful login, not only set at registration), and LastUsedAt (nil until the credential's first post-registration login; registering a credential is not "using" it).

BackupEligible/BackupState are not just descriptive: go-webauthn's own login verification compares the credential's stored BackupEligible bit against the fresh assertion's bit on every login and rejects a mismatch with "Backup Eligible flag inconsistency detected". passkey feeds the persisted flags back into every ceremony's credential list for exactly this reason — a store that returns stale or zero-valued flags from GetCredentialsByUserID/GetCredentialByID will cause later logins for a genuinely backup-eligible credential to fail this check.

Deleting credentials requires an explicit decision about the last one. passkey cannot see whether a sulis account has a password or another second factor — it only knows its own credential count for a user — so Service.DeleteCredential(ctx, userID, id string, opts DeleteOptions) rejects removing a user's only remaining credential with ErrLastCredential unless opts.AllowLast is set. Set AllowLast only after your application has independently confirmed, through its own re-authentication or explicit confirmation flow, that the account will remain reachable once this credential is gone. id here is the store's own Credential.ID, not the raw WebAuthn Credential.CredentialID.

The guard itself lives in Store.DeleteCredential(ctx, userID, id string, allowLast bool), not in ServiceService.DeleteCredential is a thin wrapper. This matters for your Store implementation: the membership check ("does id belong to userID?"), the remaining-count check, and the removal must happen as a single atomic operation with respect to any concurrent call for the same userID — the same requirement ChallengeStore.ConsumeChallenge, TokenStore.ConsumeToken, and recovery.Store.ConsumeCode already place on their own check-and-mutate operations. Without that atomicity, two concurrent calls deleting a user's last two credentials (one ID each) could each read the pre-deletion count before either delete lands, both pass the guard, and both succeed — leaving the user with zero credentials, exactly the lockout state the guard exists to prevent, reached through the guarded path. For SQL, run the count check and the DELETE in one transaction after locking the user's credential rows (SELECT ... FOR UPDATE), or express both in a single statement; a mutex-guarded in-memory store can simply perform the check and the removal while holding the same lock. Store.DeleteCredentialsByUserID (for removing every credential a user has, e.g. as part of deleting the account) applies no such guard — deleting a whole account is a stronger, presumably already-gated action.

Challenge/session keys are ceremony-scoped ("register:<userID>", "login:<ceremonyID>", "discover:<ceremonyID>") so concurrent ceremonies can't clobber each other's saved challenge. Each of the three finish paths consumes its challenge via ChallengeStore.ConsumeChallenge — an atomic fetch-and-delete — before running verification, so only one caller can ever receive a given challenge, and a failed verification still burns it (the safe direction: a rejected ceremony can't be retried against session data an attacker may have observed). A passkey.ChallengeStore should expire entries after roughly 5 minutes, matching the lifetime of a WebAuthn ceremony.

Ceremony response bodies are size-capped. go-webauthn's own body decoding (protocol.decodeBody) is a bare json.NewDecoder(body).Decode(v) with no limit, so an attacker who can reach a finish endpoint could otherwise send an arbitrarily large body and have it read fully into memory before any validation runs. passkey.WithMaxCeremonyBody(max int64) caps this (default 64 KiB); a larger body is rejected with ErrCeremonyBodyTooLarge up front, before the challenge is consumed or any JSON parsing happens. The *http.Request methods (FinishRegistration, FinishLogin, FinishDiscoverableLogin) are thin wrappers that read r.Body through http.MaxBytesReader, so the cap stops the read itself rather than buffering an oversized body first and rejecting it afterward. passkey's core no longer imports net/http at all — it works from []byte via FinishRegistrationResponse, FinishLoginResponse, and FinishDiscoverableLoginResponse, which non-net/http callers (or callers who parse the body some other way) can call directly, subject to the same cap.

passwordcheck

passwordcheck holds the checkers behind Password quality: NewBlocklist(extra ...string) over an embedded common-password corpus, NewHIBP(opts...) for the k-anonymous Have I Been Pwned range API, and All(checkers...) to run several in order and stop at the first rejection. ErrCompromised is the same error value the root package exports as sulis.ErrPasswordCompromised, so errors.Is matches under either name — the sentinel lives here because the root package's default configuration constructs a Blocklist, and an import the other way would be a cycle.

Checker there and sulis.PasswordChecker here are the same method set, so anything written against either interface satisfies both. A checker used outside sulis is handed whatever its caller passes; sulis always hands it the NFKC-normalized password.

recovery

recovery implements one-time recovery codes as a fallback second factor for when a user loses their TOTP device or passkey. NewService(store, opts...) defaults to generating 10 codes (WithCount to change it); each code is 10 bytes of crypto/rand, base32-encoded and displayed as xxxx-xxxx-xxxx-xxxx.

Generate(ctx, userID) atomically replaces the user's entire code set and returns the plaintext codes for one-time display — only their SHA-256 hashes are persisted, so the plaintext cannot be recovered later. Consume(ctx, userID, code) (remaining int, err error) normalizes the input (case, whitespace, and dash-grouping insensitive) and atomically consumes a single matching code, returning how many unused codes are left afterward, or ErrCodeInvalid (remaining is always 0 on error) if none matches. Remaining(ctx, userID) reports the unused count without consuming anything. Disable(ctx, userID) removes all codes for a user — see "Recovery codes and the 2FA lifecycle" below for its second job.

recovery.WithLimiter(l Limiter) configures a rate limiter Consume consults, keyed by "recovery:"+userID, before it ever hashes or looks up the submitted code — the interface is structurally identical to the root package's Limiter and totp.Limiter (Allow(ctx, key) error), so a single sulis.MemoryLimiter instance guards all three. A denied attempt returns ErrCodeRateLimited. The default (no limiter) is unchanged from before this option existed — recovery codes are 80 bits of crypto/rand, far larger than a TOTP code's 10^6 space, but still a value worth throttling if you don't already rate-limit this endpoint at another layer.

Recovery codes and the 2FA lifecycle

recovery only validates and consumes a code — it has no session store, no notification mechanism, and no idea what your product looks like, so it cannot do the following three things for you. A real integration should do all three itself, immediately after a successful Consume:

  1. Revoke every other active session for the user (e.g. the root package's RevokeAllSessions). A recovery-code login means the primary factor was lost, so a session an attacker already holds should not survive it — the same reasoning behind sulis's own session revocation on a password change.
  2. Record the event somewhere auditable. recovery.WithEventSink(sink) routes EventCodeConsumed (carries the new Remaining count), EventCodeRejected, EventCodesExhausted (emitted alongside EventCodeConsumed when Remaining hits 0), and EventCodeRateLimited (a denied Limiter attempt, before the code is even looked up) to an EventSink you configure — Emit(ctx, Event), same shape as the root package's EventSink, but not wire-compatible with it: Event's payload is a distinct type per package, so (unlike Limiter) one implementation cannot satisfy both interfaces. Write a small adapter if you want one unified event stream. As with the root taxonomy, no event field can hold the code or its hash.
  3. Push the user toward re-enrolling a real second factor, especially once Consume's returned remaining reaches 0 (also reported as EventCodesExhausted). Recovery codes are a bridge back to a working TOTP credential or passkey, not a permanent substitute for one.

Symmetrically, when the user's last other second factor is removed (their only TOTP credential unenrolled, their last passkey deleted), call Disable(ctx, userID) to purge whatever recovery codes are left over — a recovery code that outlives the factor it was meant to back up is no longer a fallback, it's the account's only remaining guard, silently. recovery cannot detect this moment itself (it has no visibility into totp.Store or passkey.Store), so this call is the calling application's responsibility, at the same point it would otherwise disable the 2FA setting on the account.

Store Contracts

sulis does not ship a database layer. Consumers own persistence and implement these interfaces:

  • UserStore: create, fetch, update, and delete users by ID/email. UpdateUser must apply the write only if the stored row's version still equals user.Version, incrementing it on success and returning ErrConcurrentUpdate otherwise:

    UPDATE users SET ..., version = version + 1
     WHERE id = $1 AND version = $2
    

    Zero rows affected means another writer won. Without this check, two flows that each read-modify-write the whole row can clobber each other, and the dangerous direction restores a password hash the user just rotated away from — silently undoing a reset. The library reloads and retries on ErrConcurrentUpdate, so a correct store makes the race invisible to callers. User's disable/lockout fields (DisabledAt, DisabledReason, LockedUntil, FailedLoginAttempts; see Account disable and lockout) are ordinary fields on the same struct — no new UserStore method was needed for them, the same way Version was chosen in the first place so future fields would not force interface churn. DisabledAt/LockedUntil are pointers, so they fall under the no-aliasing rule below alongside EmailVerifiedAt.

  • SessionStore: create sessions, load them by token-hash lookup, list a user's sessions, revoke one session, revoke all (or all-but-one) sessions for a user, and CleanExpired. CleanExpired is never called by the library itself — see Operational requirements. DeleteSession(ctx, userID, id) must scope its delete to both columns (DELETE FROM sessions WHERE id = ? AND user_id = ?) and return ErrSessionNotFound on zero rows affected — whether id doesn't exist at all, or exists but belongs to a different user. This is what makes RevokeSession safe to expose directly to a session-management UI: it always passes the caller's own userID, so a guessed or leaked session ID belonging to someone else is indistinguishable from a nonexistent one. DeleteUserSessionsExcept(ctx, userID, keepSessionID) is the same "sign out everywhere else" shape as a single query (DELETE FROM sessions WHERE user_id = ? AND id <> ?); keepSessionID matching nothing is not an error, since every other session for userID still counts as removable. ListUserSessions(ctx, userID) returns full Session values, TokenHash included — the same as GetSessionByTokenHash — since blanking it is Sulis.ListUserSessions's job, not the store's; see Session visibility and lifecycle.

    UpdateAuthenticatedAt(ctx, id, at) stamps a single session's AuthenticatedAt, leaving every other column untouched:

    UPDATE sessions SET authenticated_at = $2 WHERE id = $1
    

    Zero rows affected (id unknown) must return ErrSessionNotFound. This is ReAuthenticate's write path — see Step-up authentication.

    TouchSession(ctx, id, lastSeen, idleExpires) stamps LastSeenAt/IdleExpiresAt together, leaving every other column untouched:

    UPDATE sessions SET last_seen_at = $2, idle_expires_at = $3 WHERE id = $1
    

    idleExpires is nil whenever WithIdleTimeout isn't configured; a nil value must be written as SQL NULL, clearing any previously-stored deadline — an application that enables idle expiry and later disables it again must not have a stale deadline linger. Zero rows affected must return ErrSessionNotFound. This is ValidateSession's throttled liveness-touch write path — see ValidateSession for the throttle. TouchSession is deliberately its own method rather than an extra parameter folded onto UpdateAuthenticatedAt: a step-up re-authentication and a liveness heartbeat are different events from different callers at very different frequencies, and one method serving both would let a caller that means to refresh only one silently refresh the other too.

  • TokenStore:

    • CreateToken persists a new token. Every field on Token must round-trip, NonceHash included — it carries magic-link binding, and RedeemMagicLink decides whether to demand a binding nonce by looking at the NonceHash on the token the store hands back. A store that accepts the field and returns it empty turns binding off for every magic link it persists, silently, with no error anywhere. storetest's round-trip subtest asserts it.
    • ConsumeToken(ctx, hash, purpose) must atomically find the unused token matching hash and purpose and mark it used in one operation (e.g. UPDATE ... WHERE hash=? AND purpose=? AND used=false), returning ErrTokenNotFound if nothing matches and ErrTokenAlreadyUsed if it was already consumed. Lookup and mark-used are not allowed to be separate steps — that would open a race where two concurrent redemptions both succeed.
    • DeleteExpiredTokens(ctx) deletes expired tokens; also never called by the library itself.
    • DeleteUserTokens(ctx, userID, purpose) deletes all of a user's tokens for a given purpose (deleting zero is not an error) — used internally to purge outstanding password-reset tokens after a successful reset/change.
  • totp.Store: keeps a user's active (verified) credential and pending (unverified) enrollment as two separate slots, at most one of each. GetActiveTOTP/GetPendingTOTP fetch each slot (ErrTOTPNotEnrolled if empty). EnrollPending atomically checks that no active credential exists before storing cred as the new pending enrollment — the check and the write must be one atomic operation, or a concurrent ConfirmEnrollment could promote a different pending enrollment to active in the gap between them — and returns ErrTOTPAlreadyEnrolled otherwise; ReplacePending is the same write without that guard, for ReplaceEnrollment's explicit supersession. ConfirmEnrollment(ctx, userID, pendingID, counter) atomically promotes the pending enrollment to active only if it is still the exact one named by pendingID (a compare-and-swap against a concurrent EnrollPending/ReplacePending), carrying LastUsedCounter forward to whichever is greater of counter and the previously-active credential's counter, and returns ErrTOTPNotEnrolled if pendingID no longer matches. SaveTOTP persists updates to the existing active credential (in practice, Validate's counter bump) and must persist LastUsedCounter atomically with respect to concurrent Validate calls, rejecting any save that would lower it for the same credential ID, so two racing validations can't both accept the same (or an older) counter. DeleteTOTP removes both slots.

  • passkey.Store: save passkey credentials, list credentials for a user, fetch a credential by WebAuthn credential ID, delete all of a user's credentials, and rename a credential (RenameCredential returns ErrPasskeyNotFound for an unknown ID). UpdateCredentialAfterLogin persists sign count, backup state, and LastUsedAt together in one call — go-webauthn's own storage guidance says sign count, clone-warning, and backup state must be written back on every successful login so the next ceremony observes current values, and bundling them keeps that invariant from being split across calls a caller could apply out of order or only partially. DeleteCredential(ctx, userID, id string, allowLast bool) must perform its membership check, its remaining-credential-count check, and the removal as a single atomic operation — see passkey above for why a non-atomic implementation reopens the exact lockout race the guard exists to prevent.

  • passkey.ChallengeStore: SaveChallenge stores the temporary WebAuthn session data used between begin/finish calls, keyed per-ceremony (see above) with a ~5-minute TTL. ConsumeChallenge(ctx, key) must atomically fetch and delete that data in one operation (e.g. Redis GETDEL, or SQL DELETE ... RETURNING) — the same race concern as ConsumeToken: a separate get-then-delete lets two concurrent finishes of the same ceremony both read the challenge before either removes it, so both proceed past the "expired" check.

  • recovery.Store: ReplaceCodes atomically swaps a user's full code set; ConsumeCode must atomically find-and-delete a matching hash (same race concern as ConsumeToken), returning ErrCodeNotFound if absent; CountCodes; DeleteCodes.

These stores are part of the security boundary. They should enforce uniqueness where needed and persist enough data for expiry and revocation. Only some flows depend on specific sentinel errors from stores, such as ErrUserNotFound, ErrUserAlreadyExists, ErrTokenNotFound, and recovery.ErrCodeNotFound; other store errors are propagated or normalized by the service.

No store may share mutable state with its callers, in either direction. User.Metadata and Session.Metadata are maps and User.EmailVerifiedAt, User.DisabledAt, User.LockedUntil, and Session.IdleExpiresAt are each a pointer, so copying one of those structs with a plain cp := *user copies a map header and an address rather than the map and the time — which leaves the caller holding a live handle on the stored row and able to rewrite it without going through UpdateUser at all, stepping around the Version precondition rather than violating it. Copy the map (one level is enough; values inside it are the caller's business) and each pointed-to time both when storing and when returning. A store that reconstructs rows from a database read gets this for free; an in-memory or caching one does not. storetest checks it.

Proving your stores correct

Everything above is prose, and none of it is checked by the compiler: a store that returns the wrong error, or splits an atomic check-and-mutate into a read followed by a write, satisfies every interface in this module and still breaks the guarantees the library is built on. The storetest package turns those contracts into an executable suite you run against your own implementation. It is supported public API, and it is the intended integration path — not an internal test helper.

import (
    "testing"

    "github.com/borfast/sulis"
    "github.com/borfast/sulis/storetest"
)

func TestMyUserStore(t *testing.T) {
    storetest.RunUserStore(t, func() sulis.UserStore { return newMyUserStore(t) })
}

There is one Run* function per interface, all in the same shape:

Interface Suite
sulis.UserStore storetest.RunUserStore(t, factory)
sulis.SessionStore storetest.RunSessionStore(t, factory)
sulis.TokenStore storetest.RunTokenStore(t, factory)
passkey.Store storetest.RunPasskeyStore(t, factory)
passkey.ChallengeStore storetest.RunPasskeyChallengeStore(t, factory)
totp.Store storetest.RunTOTPStore(t, factory)
recovery.Store storetest.RunRecoveryStore(t, factory)

The factory must return a store observing no state from any earlier call — an empty database, a truncated schema, a fresh map. Every subtest calls it at least once and the concurrency subtests call it once per iteration, so make the reset cheap. Identifiers, addresses, and hashes the suite generates are unique per process run, and count assertions are always scoped to the users a subtest created, so a factory that can only truncate rather than recreate is still fine.

Run it with -race. The atomicity requirements are checked by racing goroutines through a shared start gate and asserting on the aggregate outcome: exactly one caller consumed the token, the user still has one passkey, the TOTP counter did not move backwards. Those subtests repeat many times, since a race that loses once proves nothing; pass -short to cut the iteration count when you are smoke-testing a slow store rather than certifying it. The suite asserts only on the documented contracts — never on storage, orderings the interfaces do not promise, or timestamp precision — so it is equally valid against SQL, key-value, and in-memory implementations.

memstore is the reference implementation: an in-memory version of every interface above, which passes the whole suite. It is worth reading before writing your own — each type shows where the atomic boundary has to be, with one mutex standing in for the transaction or conditional statement a database needs. It is also a working store for tests, examples, and local development:

users := memstore.NewUserStore()
sessions := memstore.NewSessionStore()
tokens := memstore.NewTokenStore()
auth, err := sulis.New(users, sessions, tokens, sulis.NoSecondFactors{})

It is not for production: nothing survives a restart, nothing is shared between processes, and nothing is bounded except by the delete and cleanup methods.

Security Notes

See SECURITY.md for how to report a vulnerability and the supported-version policy, and docs/threat-model.md for the full threat model — in-scope threats and their shipped mitigations, what's explicitly out of scope, and known residual risks.

  • Token.TokenHash stores a SHA-256 hash of a reset, magic-link, two-factor, or email-verification token. Raw tokens are returned once for delivery and should never be persisted. Token.Email is set for magic-link tokens issued before the user account exists, and for email-verification tokens (bound to the address they prove, so a later email change invalidates them); it is empty for password-reset and two-factor tokens.
  • Session tokens are opaque bearer tokens. SessionStore implementations persist only TokenHashSession has no Token field to accidentally persist; the raw token is returned beside the *Session at issuance (see IssueSession) and nowhere else — and perform GetSessionByTokenHash lookups against the hash of the presented session token rather than the raw token. ValidateSession likewise never returns the raw token to the caller.
  • TOTP secrets (totp.Credential.Secret) and passkey public keys are handed to your stores as-is, unless you configure totp.WithEncryptor — see Encrypting stored secrets — in which case your totp.Store only ever sees the configured Encryptor's ciphertext. Recovery codes and all sulis tokens/sessions are hashed before your store ever sees them. See Operational requirements for what an unconfigured Encryptor implies for TOTP secrets specifically.
  • Security events (WithEventSink) never carry credential material, stored hashes, submitted email addresses, or any other caller-supplied string beyond the RequestInfo you passed in — see Security events. Emission is best effort and cannot fail or slow a flow into failure; a sink that panics is contained.

Operational requirements

These are things the library deliberately leaves to the consumer. Skipping them weakens the security properties described above.

Rate limiting is on by default. sulis.New installs an in-process MemoryLimiter — a token bucket that resists password guessing, reset flooding, and magic-link flooding without any wiring. It is consulted on two dimensions: per account ("password:"+email, "reset:"+email, "magic:"+email) and, when you pass a RequestInfo carrying an IP, per client address ("password:ip:"+ip, and so on). Per-account budgets are deliberately generous and per-IP budgets tight, so an attacker can neither rotate the email to escape throttling nor lock a victim out by exhausting the victim's own allowance.

The default is per process: with several instances behind a load balancer each enforces its own budget. Supply a shared implementation with WithLimiter for a multi-instance deployment — the interface is one method, Allow(ctx, key) error. MemoryLimiter also satisfies totp.Limiter and recovery.Limiter structurally, so one instance can guard all three; each subpackage's service still needs it passed explicitly, via totp.WithLimiter/recovery.WithLimiter, since neither imports the root package.

To turn throttling off — for instance when an upstream gateway already enforces limits — call WithoutRateLimiting(). That is deliberately a visible line of code rather than the consequence of not writing one.

Token-redemption calls (ResetPassword, RedeemMagicLink, CompleteTwoFactor, VerifyEmail) are deliberately not throttled: the guessable space there is a 256-bit random token, not a password or a six-digit code, so rate limiting does not meaningfully raise the cost of an attack. recovery.Consume has an 80-bit code space of its own — larger still — but, unlike these token-redemption calls, it does accept an optional recovery.WithLimiter, since a recovery code is meant to be a rarely-used fallback rather than a value a legitimate caller ever needs to present at volume.

Schedule cleanup yourself. TokenStore.DeleteExpiredTokens and SessionStore.CleanExpired exist so expired rows don't accumulate forever, but sulis never calls either — it runs no background workers. Run them on a periodic job (cron, a ticker goroutine, etc.). ValidateSession does delete a session it discovers is expired at validation time, but that's incidental to the read path, not a substitute for sweeping sessions and tokens that are never revisited.

Cookie-mode Authenticate ships its own CSRF defenses — wiring them up is still yours to do. SessionCookie always sets HttpOnly/Secure/SameSite=Lax/Path=/ and a __Host--prefixed name by default, RequireSameOrigin checks Sec-Fetch-Site/Origin on state-changing requests, and IssueCSRFToken/RequireCSRFToken/VerifyCSRFToken are a constant-time-compared double-submit token — see Cookie sessions and CSRF for all of it, including the default TokenSource and the deliberate policy on requests that send neither Sec-Fetch-Site nor Origin. What's still on you: actually calling SessionCookie/ClearSessionCookie in your login/logout handlers, wrapping the routes that accept a cookie-sourced session in RequireSameOrigin and/or RequireCSRFToken, and rendering IssueCSRFToken's value into your pages. None of this applies if you configure WithTokenSource(TokenSourceBearerOnly) and never call SessionCookie — a Bearer-only deployment was never CSRF-exposed.

A PasswordChecker that reaches the network is your outbound dependency. The default (passwordcheck.NewBlocklist()) makes no requests at all. Adding passwordcheck.NewHIBP() puts an HTTPS call to api.pwnedpasswords.com on the critical path of registration, password change, and password reset — allow it through egress filtering, watch its latency (it is bounded at 5s by default, on top of the Argon2 hash the user is already waiting for), and decide deliberately whether it fails open (the default) or closed. It is never on the login path.

Configure totp.WithEncryptor before production. By default, totp.Service hands your totp.Store a plaintext base32 secret (Credential.Secret) — there is no encryption unless you configure one. totp.WithEncryptor(totp.NewAESEncryptor(key)) fixes this application-side, so a store implementation never needs its own envelope encryption to be safe: see Encrypting stored secrets for the AES-256-GCM implementation, key rotation, and the fail-closed behavior on a wrong or missing key. Leaving it unconfigured means a database compromise hands an attacker every enrolled user's shared secret, usable to generate valid codes indefinitely and silently, with no work factor slowing that down the way Argon2 does for passwords.

Notifying the OLD address on an email change is not optional, and sulis cannot do it for you. sulis sends no mail at all. The ChangeEmail token goes to the new address — that is what proves the requester can receive there — but you must also notify the old address twice: once when a change is staged, and once when ConfirmEmailChange makes it live. Changing an account's email is an account-takeover primitive, and that notification is the only message that reaches an address the attacker does not control: the first while the pending change can still be undone, the second at least in time to start recovery. An application that delivers only the confirmation link has built a takeover flow with no victim-visible signal. Gate ChangeEmail behind RequireRecentAuth while you are there. See Changing an email address and docs/threat-model.md.

Registration is not rate limited — put a limiter in front of it. The default MemoryLimiter guards password verification, password reset, and magic-link issuance, but Register has no budget of its own: it is the one public entry point that creates a row on every successful call, so an unthrottled registration endpoint is a store-flooding vector (and, on a deployment that mails a verification link, a way to send mail from your domain to addresses of somebody else's choosing). It runs a full Argon2 hash before it writes anything, so the same endpoint is a CPU-exhaustion surface even for calls that go on to fail. (CreateMagicLinkToken is not the same hazard: it is rate limited, and it deliberately creates no user row until a link is actually redeemed.) Rate limit it upstream — at your gateway, or with your own Allow call keyed on the client address before you call Register — and consider a CAPTCHA or an invite gate if the product allows one. This is deliberate rather than an oversight: the right budget for signups is a product decision (an internal tool and a consumer product want wildly different numbers), and a wrong default here refuses legitimate users rather than merely slowing an attacker.

CreatePasswordResetToken cannot be used to enumerate registered addresses. Like Login/VerifyPassword, it normalizes the unknown-address case away: an unregistered email returns ("", nil), the same shape a genuine issuance takes from the caller's perspective, rather than ErrUserNotFound. The unknown-address path also performs the same token generation and hashing work the known-address path does before discarding the result, so the two paths can't be distinguished by the work they perform either — only by a residual asymmetry this can't remove: the known-address path writes a token row and the unknown-address path never does, since there's no user to attach one to (the same kind of documented gap as VerifyPassword's dummy-hash equalization — equal work, not a provable-equal-latency guarantee across a storage boundary). Your HTTP handler can safely return the same generic response ("if that address is registered, we've sent a reset link") unconditionally, with no flattening of its own required.

Admin tooling that has already authenticated an operator and genuinely needs to know whether an address is registered should call CreatePasswordResetTokenStrict instead, which returns ErrUserNotFound verbatim. Never wire it to a public-facing endpoint — that reopens the exact oracle CreatePasswordResetToken exists to close. (CreateMagicLinkToken doesn't have this problem at all: it never returns a not-found error, since it defers user creation to redemption.)

Sessions are revoked on password change by default. RevokeSessionsOnPasswordChange defaults to true, so both ChangePassword and ResetPassword delete every session belonging to the user (and purge its outstanding password-reset, two-factor, and magic-link tokens — the last two unconditionally, whatever this setting says) as part of applying the new password. Pass WithRevokeSessionsOnPasswordChange(false) to opt out. Because this revokes the caller's own current session too, ChangePassword does not return a new one — call IssueSessionUnchecked(ctx, userID, sulis.AuthMethodPassword) yourself immediately afterward if you want the calling client to stay logged in; ChangePassword itself hands back no Authentication, so this is your application vouching that the same already-authenticated caller who just changed their password should stay signed in.

New sessions are blocked for unverified accounts by default. RequireVerifiedEmail defaults to true: Login, IssueSession, IssueSessionUnchecked, CreateTwoFactorToken, CompleteTwoFactor, and RefreshSession all return ErrEmailNotVerified for an account whose EmailVerifiedAt is still nil. Register's signup session and RedeemMagicLink (which verifies the email itself before issuing a session) are exempt — and Register's exemption covers that one session only, which is why RefreshSession is on the list: without it an unverified account could rotate its signup session indefinitely and never verify anything. If your application has no email verification flow wired up — no CreateEmailVerificationToken/VerifyEmail, and no magic links — you must pass WithRequireVerifiedEmail(false), or users will be able to register but never sign in again once their first session expires. Migration note: this is a behavior change for existing consumers — previously an unverified account could log in indefinitely. Either wire up verification (or magic links, which self-verify) or opt out explicitly.

Versioning

sulis is pre-1.0 (v0.x) for the duration of the security-hardening plan (docs/superpowers/plans/2026-08-17-security-hardening-v1/PLAN.md) that closed the second-factor bypass, added the safe-by-default posture, and built the store-contract conformance suite, among everything else CHANGELOG.md's Unreleased entry lists. Before 1.0, the public API can and does break between commits on this branch — CHANGELOG.md's migration guide covers every break shipped so far, and there is no compatibility promise yet.

Once this plan completes and sulis tags v1.0.0, it adopts a Go-1-style compatibility promise for the public API of sulis, totp, passkey, recovery, and passwordcheck: exported identifiers, exported types' exported fields, and documented behavior will not change incompatibly within the v1 line. A v1.x release only adds; it does not remove or repurpose anything already there, and only a v2 major version may break compatibility, following the usual Go convention of a /v2 module path.

Two exceptions, stated up front so neither is a surprise later:

  • Store contracts may still evolve behind storetest. UserStore, SessionStore, TokenStore, and each subpackage's Store/ChallengeStore interfaces are consumer-implemented; storetest's conformance suite can still tighten a documented requirement that a compliant implementation already satisfies — the same kind of clarification this hardening pass made repeatedly (see the migration guide) — without that being a compatibility break for the interface's Go signature. It is still called out in the CHANGELOG when it happens, and a genuine signature change to a store interface is a v1-breaking change like any other.
  • store/sql is a separate module and versions independently. github.com/borfast/sulis/store/sql (the SQLite and PostgreSQL reference implementations) is not covered by the root module's v1 promise above; it follows its own semver.

This is a compatibility promise, not a security-support policy — see SECURITY.md for which versions receive security fixes.

Documentation

Overview

Package sulis is a Go authentication library for consumer-owned persistence: password login, magic-link login, two-factor pending-login tokens, password reset, email verification, server-side sessions, and the HTTP middleware that attaches an authenticated user and session to a request context. The totp, passkey, and recovery subpackages add TOTP, WebAuthn passkeys, and recovery codes as second factors or standalone credentials; passwordcheck screens new passwords against known-compromised values.

Store-interface architecture

sulis ships no database driver and stores nothing itself. Every piece of state it needs — users, sessions, and tokens for the root package; TOTP credentials, passkey credentials and their WebAuthn challenges, and recovery codes for the respective subpackages — is read and written through a small interface (UserStore, SessionStore, TokenStore, and each subpackage's own Store) that the consumer implements against whatever they already run: Postgres, SQLite, DynamoDB, or anything else. Those interfaces document requirements no compiler can check — ConsumeToken must find-and-mark a token used in one atomic step, UpdateUser must reject a write built from a stale read, DeleteSession must scope its delete to the owning user — because a store that gets one of them wrong satisfies the interface and still breaks the guarantee the library is built on. See "Store contracts" below for how to prove an implementation correct instead of hoping it is.

Safe by default

Every default is chosen so that calling New and nothing else is already the secure configuration, not a starting point that still needs hardening: an in-process rate limiter guards password, reset, and magic-link attempts before any other option is set; new passwords are screened against a breach corpus; a new session is refused for an account whose email isn't verified yet, including the rotation RefreshSession would otherwise mint from a signup session; a WebAuthn passkey requires user verification (a PIN or a biometric), not bare possession of an unlocked device; cookie sessions carry HttpOnly, Secure, SameSite=Lax, and a __Host- name; and changing a password revokes every other session on the account. Every one of these can be turned off — WithoutRateLimiting, WithPasswordChecker(nil), WithRequireVerifiedEmail(false), passkey.WithUserVerification with protocol.VerificationDiscouraged, WithRevokeSessionsOnPasswordChange(false) — but each is a visible call a reviewer can find, never the silent consequence of forgetting one. See the README's "Operational requirements" section for the full list and the reasoning behind each default.

A minimal end-to-end flow

Registration and login against the reference in-memory stores (package memstore — fine for this, tests, and local development; never production):

users, sessions, tokens := memstore.NewUserStore(), memstore.NewSessionStore(), memstore.NewTokenStore()

auth, err := sulis.New(users, sessions, tokens, sulis.NoSecondFactors{})
if err != nil {
	log.Fatal(err)
}

ri := sulis.RequestInfo{IP: r.RemoteAddr}
user, session, rawToken, err := auth.Register(ctx, email, password, ri)
if err != nil {
	return err // ErrUserAlreadyExists, ErrInvalidEmail,
	            // ErrPasswordTooShort/TooLong, ErrPasswordCompromised, ...
}
setSessionCookie(auth.SessionCookie(rawToken, session.ExpiresAt))

// Later, on a login request:
result, err := auth.Login(ctx, email, password, ri)
if err != nil {
	return err // ErrInvalidCredentials, ErrRateLimited, ErrEmailNotVerified, ...
}
if result.NeedsSecondFactor {
	// No session exists yet — see CompleteTwoFactor and the totp,
	// passkey, and recovery subpackages.
	return promptForSecondFactor(result.User, result.PendingToken)
}
setSessionCookie(auth.SessionCookie(result.SessionToken, result.Session.ExpiresAt))

A NoSecondFactors application still gets rate limiting, password screening, email-verification gating, and hashed everything for free; a real SecondFactorChecker implementation (backed by totp.Store, passkey.Store, or both) is what turns the NeedsSecondFactor branch above from dead code into two-factor authentication. See package example tests for compiler-checked walkthroughs of password login with a second factor, magic links, passkeys, password reset, and email change.

Store contracts

Every store interface's doc comment states its atomicity, scoping, and error-sentinel requirements; the README's "Store Contracts" section collects them with reference SQL. Package storetest turns those contracts into an executable conformance suite — supported public API and the intended integration path, not an internal test helper:

func TestMyUserStore(t *testing.T) {
	storetest.RunUserStore(t, func() sulis.UserStore { return newMyUserStore(t) })
}

Package memstore is a reference implementation of every interface in this module (root and subpackages), written to be read end to end and proven, by that same suite, to satisfy every contract it documents.

Security events

EventKind's constants (events.go) are a closed, dot-namespaced taxonomy of this root package's own security-relevant decisions — a password refused, a second factor demanded, a session issued or expired, a limiter tripped, an account disabled, and more. WithEventSink wires a sink through; NewSlogSink adapts a *slog.Logger in one line. See Event's doc comment for what a reported event may and may not contain.

The totp and passkey subpackages have no event sink of their own; wiring one through them is a separate piece of work (see the T509 Decisions row in PROGRESS.md). recovery does: its own independent EventKind, Event, EventSink, and WithEventSink (recovery/events.go), deliberately not wire-compatible with the root taxonomy — see recovery.EventSink's doc comment for why. An application wanting one unified event stream writes a small adapter translating a recovery.Event into whatever shape its own sink expects.

Where to go next

The README documents every flow (password reset, magic link, two-factor, email verification, step-up re-authentication, cookie sessions and CSRF, security events) at the depth a doc comment can't. SECURITY.md covers how to report a vulnerability and the supported-version policy; docs/threat-model.md names the in-scope threats, the shipped mitigation for each, what's explicitly out of scope, and the residual risks — such as the default rate limiter being per-process rather than shared across instances — that remain the deploying application's to manage.

Example (EmailChange)

Example_emailChange shows staging and confirming an email change, and the notification obligation that falls on the caller rather than on sulis.

package main

import (
	"context"
	"fmt"

	"github.com/borfast/sulis"
	"github.com/borfast/sulis/memstore"
)

func main() {
	ctx := context.Background()

	auth, err := sulis.New(
		memstore.NewUserStore(), memstore.NewSessionStore(), memstore.NewTokenStore(),
		sulis.NoSecondFactors{},
	)
	if err != nil {
		fmt.Println("setup:", err)
		return
	}

	ri := sulis.RequestInfo{IP: "203.0.113.13"}
	user, _, sessionToken, err := auth.Register(ctx, "old-address@example.com", "correct-battery-staple", ri)
	if err != nil {
		fmt.Println("register:", err)
		return
	}

	// sulis does not send mail. The raw token below is for delivery to the
	// NEW address, to prove control of it.
	//
	// SECURITY (notify the OLD address): the caller MUST ALSO notify the
	// OLD address that a change was requested — unconditionally, and with
	// no token attached, since it needs no confirmation. That notification
	// is how the account's rightful owner catches and can still undo a
	// takeover attempt (an attacker who set this in motion controls the new
	// address, never the old one) while the pending change hasn't taken
	// effect yet. Skipping it turns a recoverable takeover attempt into a
	// silent, completed one.
	token, err := auth.ChangeEmail(ctx, user.ID, "new-address@example.com")
	if err != nil {
		fmt.Println("change:", err)
		return
	}

	updated, err := auth.ConfirmEmailChange(ctx, token)
	if err != nil {
		fmt.Println("confirm:", err)
		return
	}

	// Confirming an email change revokes every session on the account: the
	// identity a still-live session was issued against has just changed.
	_, _, err = auth.ValidateSession(ctx, sessionToken)

	fmt.Println("live email:", updated.Email)
	fmt.Println("old session still valid:", err == nil)
}
Output:
live email: new-address@example.com
old session still valid: false
Example (Passkey)

Example_passkey shows the shape of a passkey registration and a passkey login: starting a ceremony, and what happens once its browser-signed response comes back. Finishing either ceremony needs a real, signed WebAuthn response from a browser and an authenticator, which this process-local example has no way to produce, so it stops at that boundary — the calls that would follow are named in comments instead of faked.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/borfast/sulis"
	"github.com/borfast/sulis/memstore"
	"github.com/borfast/sulis/passkey"
)

func main() {
	ctx := context.Background()

	credentials := memstore.NewPasskeyStore()
	challenges := memstore.NewChallengeStore()
	svc, err := passkey.NewService(credentials, challenges, passkey.WebAuthnConfig{
		RPDisplayName: "Example App",
		RPID:          "example.com",
		RPOrigins:     []string{"https://example.com"},
	})
	if err != nil {
		fmt.Println("setup:", err)
		return
	}

	auth, err := sulis.New(
		memstore.NewUserStore(), memstore.NewSessionStore(), memstore.NewTokenStore(),
		sulis.NoSecondFactors{},
	)
	if err != nil {
		fmt.Println("setup:", err)
		return
	}

	ri := sulis.RequestInfo{IP: "203.0.113.9"}
	user, _, _, err := auth.Register(ctx, "morgan@example.com", "correct-battery-staple", ri)
	if err != nil {
		fmt.Println("register:", err)
		return
	}

	// RequireVerifiedEmail defaults to true, so IssueSessionUnchecked below
	// would otherwise refuse with ErrEmailNotVerified. See the equivalent
	// step in the password + 2FA example for why.
	evToken, err := auth.CreateEmailVerificationToken(ctx, user.ID)
	if err != nil {
		fmt.Println("create verification token:", err)
		return
	}
	if _, err := auth.VerifyEmail(ctx, evToken); err != nil {
		fmt.Println("verify email:", err)
		return
	}

	pu := &passkey.User{ID: []byte(user.ID), Name: user.Email, DisplayName: user.Email}

	// Registration: BeginRegistration hands the browser a challenge for its
	// authenticator to sign.
	if _, err := svc.BeginRegistration(ctx, pu); err != nil {
		fmt.Println("begin registration:", err)
		return
	}
	// The browser's navigator.credentials.create() response is later
	// handed, as raw bytes, to svc.FinishRegistrationResponse(ctx, pu,
	// body) — or FinishRegistration(ctx, pu, r) for an *http.Request — which
	// verifies the signature and saves the resulting *passkey.Credential.
	// A fixture stands in below for what that call would have stored, so
	// BeginLogin below has a credential to challenge.
	if err := credentials.SaveCredential(ctx, &passkey.Credential{
		ID:           "example-credential",
		UserID:       user.ID,
		CredentialID: []byte("example-credential-id"),
		PublicKey:    []byte("example-public-key"),
		CreatedAt:    time.Now(),
	}); err != nil {
		fmt.Println("seed credential:", err)
		return
	}

	// Login: BeginLogin hands the browser a challenge for whichever
	// registered credential it holds to sign.
	if _, _, err := svc.BeginLogin(ctx, pu); err != nil {
		fmt.Println("begin login:", err)
		return
	}
	// FinishLoginResponse(ctx, pu, ceremonyID, body) would verify the signed
	// assertion and return the Credential that produced it — rejecting a
	// sign-count anomaly with passkey.ErrCloneWarning, a signal of possible
	// cloning rather than a routine failure. sulis itself never checks a
	// WebAuthn signature; that verification is entirely the passkey
	// package's job. Once it succeeds, the caller — not sulis — is the one
	// asserting the factor passed:
	//
	// SECURITY (IssueSessionUnchecked's vouching semantics): this method
	// performs no credential check of its own. Calling it means THIS CODE
	// is vouching that userID just completed every factor the application
	// requires — here, a verified passkey assertion — not that sulis
	// independently confirmed it. Never call it on the strength of a bare
	// client claim.
	session, _, err := auth.IssueSessionUnchecked(ctx, user.ID, sulis.AuthMethodPasskey)
	if err != nil {
		fmt.Println("issue session:", err)
		return
	}

	fmt.Println("session method:", session.Method)
}
Output:
session method: passkey
Example (PasswordLoginWithTwoFactor)

Example_passwordLoginWithTwoFactor shows a password login for an account enrolled in TOTP: the password is only the first factor, and no session exists until the second factor is verified too.

package main

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/borfast/sulis"
	"github.com/borfast/sulis/memstore"
	"github.com/borfast/sulis/totp"
)

// totpSecondFactor adapts a totp.Store to sulis.SecondFactorChecker: a user
// has a second factor exactly when they have an active (verified) TOTP
// credential. A real application wires the equivalent against a
// passkey.Store, or both, and answers false only when neither is enrolled.
type totpSecondFactor struct{ store totp.Store }

func (c totpSecondFactor) HasSecondFactor(ctx context.Context, userID string) (bool, error) {
	_, err := c.store.GetActiveTOTP(ctx, userID)
	switch {
	case err == nil:
		return true, nil
	case errors.Is(err, totp.ErrTOTPNotEnrolled):
		return false, nil
	default:

		return false, err
	}
}

func main() {
	ctx := context.Background()

	totpStore := memstore.NewTOTPStore()
	auth, err := sulis.New(
		memstore.NewUserStore(), memstore.NewSessionStore(), memstore.NewTokenStore(),
		totpSecondFactor{store: totpStore},
	)
	if err != nil {
		fmt.Println("setup:", err)
		return
	}
	totpSvc, err := totp.NewService(totpStore, "ExampleApp")
	if err != nil {
		fmt.Println("setup:", err)
		return
	}

	ri := sulis.RequestInfo{IP: "203.0.113.5"}
	user, _, _, err := auth.Register(ctx, "kai@example.com", "correct-battery-staple", ri)
	if err != nil {
		fmt.Println("register:", err)
		return
	}

	// RequireVerifiedEmail defaults to true, so Login below would otherwise
	// refuse with ErrEmailNotVerified: Register's own signup session is
	// exempt, but a later Login is not. A real application verifies email
	// out of band (VerifyEmail, or a redeemed magic link); this stands in
	// for that having already happened.
	evToken, err := auth.CreateEmailVerificationToken(ctx, user.ID)
	if err != nil {
		fmt.Println("create verification token:", err)
		return
	}
	if _, err := auth.VerifyEmail(ctx, evToken); err != nil {
		fmt.Println("verify email:", err)
		return
	}

	// Seed an already-enrolled, already-confirmed TOTP credential directly
	// through the store, bypassing Service.Enroll/ConfirmEnrollment's code
	// exchange. That's fixture setup, not the flow this example
	// demonstrates: this whole example runs in well under a second, so a
	// code accepted at enrollment would still be the current time step's
	// code moments later at login, and Validate's replay check below would
	// correctly refuse it as a genuine reuse. Recording LastUsedCounter as
	// 0 here keeps that fixture out of the real check's way.
	secret, _, err := totpSvc.Enroll(ctx, user.ID, user.Email)
	if err != nil {
		fmt.Println("enroll:", err)
		return
	}
	pending, err := totpStore.GetPendingTOTP(ctx, user.ID)
	if err != nil {
		fmt.Println("pending:", err)
		return
	}
	if _, err := totpStore.ConfirmEnrollment(ctx, user.ID, pending.ID, 0); err != nil {
		fmt.Println("confirm:", err)
		return
	}

	// The password is the FIRST factor only.
	result, err := auth.Login(ctx, "kai@example.com", "correct-battery-staple", ri)
	if err != nil {
		fmt.Println("login:", err)
		return
	}

	// SECURITY: branch on NeedsSecondFactor. A non-nil *LoginResult is not
	// proof of a session by itself — treating it as "logged in" here would
	// defeat two-factor authentication entirely.
	if !result.NeedsSecondFactor {
		fmt.Println("expected a pending second factor")
		return
	}

	// The application collects a code from the user's authenticator app and
	// verifies it independently — sulis never sees the TOTP secret, and has
	// no way to check this itself.
	code, err := totpSvc.Generate(secret, time.Now())
	if err != nil {
		fmt.Println("generate:", err)
		return
	}
	if err := totpSvc.Validate(ctx, user.ID, code); err != nil {
		// totp.ErrTOTPInvalid, totp.ErrTOTPNotEnrolled, totp.ErrTOTPNotVerified,
		// totp.ErrTOTPReplayed, or totp.ErrTOTPRateLimited.
		fmt.Println("validate:", err)
		return
	}

	// Only now, with both factors verified, does a session exist.
	final, err := auth.CompleteTwoFactor(ctx, user.ID, result.PendingToken, ri)
	if err != nil {
		fmt.Println("complete:", err)
		return
	}

	fmt.Println("needs second factor:", result.NeedsSecondFactor)
	fmt.Println("session issued:", final.Session != nil)
}
Output:
needs second factor: true
session issued: true
Example (PasswordReset)

Example_passwordReset shows requesting and redeeming a password-reset token, including the response an unregistered address gets.

package main

import (
	"context"
	"fmt"

	"github.com/borfast/sulis"
	"github.com/borfast/sulis/memstore"
)

func main() {
	ctx := context.Background()

	auth, err := sulis.New(
		memstore.NewUserStore(), memstore.NewSessionStore(), memstore.NewTokenStore(),
		sulis.NoSecondFactors{},
	)
	if err != nil {
		fmt.Println("setup:", err)
		return
	}

	ri := sulis.RequestInfo{IP: "203.0.113.11"}
	if _, _, _, err := auth.Register(ctx, "priya@example.com", "correct-battery-staple", ri); err != nil {
		fmt.Println("register:", err)
		return
	}

	// SECURITY (empty-token-means-unknown-email): an unregistered address
	// gets back ("", nil) — the same shape a real issuance takes from the
	// caller's side, and NOT distinguishable from it by error, return
	// value, or (CreatePasswordResetToken does the same generate-and-discard
	// work either way) the time it takes. A handler must render this
	// exactly like the known-address case ("if that address is registered,
	// we've sent a link") rather than branching on it — branching here is
	// exactly how a "forgot password" form turns into an account-existence
	// oracle.
	token, err := auth.CreatePasswordResetToken(ctx, "nobody@example.com", ri)
	if err != nil {
		fmt.Println("unexpected error:", err)
		return
	}
	fmt.Println("token for unknown address is empty:", token == "")

	token, err = auth.CreatePasswordResetToken(ctx, "priya@example.com", ri)
	if err != nil {
		fmt.Println("create token:", err)
		return
	}

	if err := auth.ResetPassword(ctx, token, "another-battery-staple"); err != nil {
		fmt.Println("reset:", err)
		return
	}

	_, err = auth.VerifyPassword(ctx, "priya@example.com", "another-battery-staple", ri)
	fmt.Println("new password verifies:", err == nil)
}
Output:
token for unknown address is empty: true
new password verifies: true

Index

Examples

Constants

View Source
const (
	// CSRFCookieName is the cookie IssueCSRFToken sets and
	// RequireCSRFToken/VerifyCSRFToken read the expected value from.
	// Unlike the session cookie it is intentionally NOT HttpOnly: a
	// same-origin script must be able to read it, to mirror it into
	// CSRFHeaderName on the requests it makes — that same-origin-only
	// readability, enforced by the browser regardless of this cookie's own
	// attributes, is the property the whole pattern rests on.
	CSRFCookieName = "__Host-csrf_token"

	// CSRFHeaderName is the request header VerifyCSRFToken checks first
	// for the client's echoed-back copy of the token.
	CSRFHeaderName = "X-CSRF-Token" // #nosec G101 -- a header name, not a credential

	// CSRFFormField is the fallback form field VerifyCSRFToken checks when
	// CSRFHeaderName is absent, for a traditional <form> POST that can't
	// set a custom header — render it as a hidden input alongside the
	// form.
	CSRFFormField = "csrf_token" // #nosec G101 -- a field name, not a credential

)

Double-submit CSRF defense.

This is meaningful only for cookie-authenticated requests: a Bearer token is never attached to a request by the browser on its own, so a forged cross-site request has nothing to ride along with in the first place. A deployment configured with WithTokenSource(TokenSourceBearerOnly) — one that never calls SessionCookie either — needs none of this; see the README's "Cookie sessions and CSRF" section.

This is a PURE double-submit: CSRFCookieName's value is a bare random token, not cryptographically bound to the session that requested it (no HMAC over a session ID, no server-side lookup). By itself that means anyone who can get their own chosen value written into that cookie for this origin could echo the very same value back in CSRFHeaderName/ CSRFFormField themselves, defeating the check — the classical weakness of a bare double-submit token versus a session-bound one. This package closes that gap by layering, not by binding the token: CSRFCookieName carries the __Host- prefix, so neither a sibling subdomain nor a network attacker without HTTPS can set it for this origin in the first place, and RequireSameOrigin adds an independent, Fetch-Metadata-based check that doesn't depend on cookie contents at all. Treat IssueCSRFToken/RequireCSRFToken/VerifyCSRFToken as one layer of a defense meant to be combined with __Host- and RequireSameOrigin, not as a standalone guarantee.

View Source
const (
	ReasonUserNotFound      = "user_not_found"
	ReasonNoPassword        = "no_password"
	ReasonWrongPassword     = "wrong_password"
	ReasonAccountDisabled   = "account_disabled"
	ReasonAccountLocked     = "account_locked"
	ReasonEmailNotVerified  = "email_not_verified"
	ReasonFactorCheckFailed = "factor_check_failed"
	ReasonTokenInvalid      = "token_invalid"
	ReasonTokenExpired      = "token_expired"
	ReasonTokenAlreadyUsed  = "token_already_used"
	ReasonUserMismatch      = "user_mismatch"
	ReasonBindingMismatch   = "binding_mismatch"
	ReasonHashFailed        = "hash_failed"
	ReasonStoreFailed       = "store_failed"
	ReasonPasswordChanged   = "password_changed"
	ReasonIdleTimeout       = "idle_timeout"
	ReasonAbsoluteExpiry    = "absolute_expiry"
	ReasonCSRFTokenInvalid  = "csrf_token_invalid" // #nosec G101 -- a reason label, not a credential
	ReasonCrossSite         = "cross_site"
	ReasonOriginNotAllowed  = "origin_not_allowed"
)

Reason labels, the closed set of values MetaReason can carry. They are fixed strings chosen by this package: never an error message, never anything a caller supplied.

View Source
const (
	ScopeSingleSession = "single"
	ScopeAllSessions   = "all"
)

Values for MetaScope on EventSessionRevoked.

View Source
const (
	DimensionAccount = "account"
	DimensionIP      = "ip"
)

Values for MetaDimension on EventRateLimitTripped.

Variables

View Source
var (
	// User errors.
	ErrUserNotFound      = errors.New("sulis: user not found")
	ErrUserAlreadyExists = errors.New("sulis: user already exists")
	// ErrConcurrentUpdate is returned by UserStore.UpdateUser when the write
	// was built from a stale read and another writer won the race.
	ErrConcurrentUpdate = errors.New("sulis: concurrent update")

	// Credential errors.
	ErrInvalidCredentials = errors.New("sulis: invalid credentials")

	// Authentication errors.
	//
	// ErrNotAuthenticated is returned by IssueSession when given the zero
	// value Authentication{} (or any Authentication not obtained by
	// completing a factor sulis itself verified, since nothing outside this
	// package can construct one otherwise). It means there is no proof of
	// authentication to act on, not that a specific credential was wrong.
	ErrNotAuthenticated = errors.New("sulis: not authenticated")

	// Session errors.
	ErrSessionNotFound = errors.New("sulis: session not found")
	ErrSessionExpired  = errors.New("sulis: session expired")

	// ErrReauthRequired is returned by RequireRecentAuth when a session's
	// AuthenticatedAt is older than the caller's maxAge. It means the
	// session is otherwise valid — ValidateSession would still accept it —
	// but too stale to authorize a step-up-gated operation without proving
	// the credential again via ReAuthenticate.
	ErrReauthRequired = errors.New("sulis: recent authentication required")

	// Token errors.
	ErrTokenInvalid     = errors.New("sulis: invalid token")
	ErrTokenNotFound    = errors.New("sulis: token not found")
	ErrTokenExpired     = errors.New("sulis: token expired")
	ErrTokenAlreadyUsed = errors.New("sulis: token already used")

	// Password policy errors.
	ErrPasswordTooShort = errors.New("sulis: password too short")
	ErrPasswordTooLong  = errors.New("sulis: password too long")
	// ErrPasswordCompromised is returned by every path that sets a password
	// — Register, ChangePassword, ResetPassword, SetInitialPassword — when
	// the configured PasswordChecker recognises the password as commonly
	// used, expected, or previously breached. It is never returned by
	// VerifyPassword, Login, or ReAuthenticate: see WithPasswordChecker for
	// why screening happens where a password is chosen and not where it is
	// proven.
	//
	// It is the very same error value as passwordcheck.ErrCompromised, not a
	// copy of it, so errors.Is matches under either name. The value has to be
	// born in that package rather than here: sulis's default configuration
	// constructs a passwordcheck.Blocklist, so an import in the other
	// direction would be a cycle, and two separate sentinels would silently
	// break errors.Is for anyone who compared against the wrong one.
	ErrPasswordCompromised = passwordcheck.ErrCompromised

	// Email validation errors.
	ErrInvalidEmail = errors.New("sulis: invalid email")

	// Email verification errors.
	ErrEmailNotVerified = errors.New("sulis: email not verified")

	// Rate limiting.
	ErrRateLimited = errors.New("sulis: rate limited")

	// Account status errors.
	//
	// ErrAccountDisabled is returned once a credential has verified for an
	// account DisableUser marked disabled — VerifyPassword checks this only
	// after a successful password verification, so a caller who has not
	// proven the password cannot use it to learn whether an account exists
	// and is disabled. ValidateSession also returns it for a pre-existing
	// session belonging to a disabled account, so disabling takes effect on
	// every live session immediately rather than only on the next login.
	ErrAccountDisabled = errors.New("sulis: account disabled")
	// ErrAccountLocked is returned the same way — only after a credential
	// has verified — for an account whose LockedUntil (set by the optional
	// automatic lockout; see WithFailureLockout) has not yet passed. Unlike
	// ErrAccountDisabled, it is not checked by ValidateSession: a lockout
	// throttles new authentication attempts, it does not invalidate a
	// session already issued before the lockout began.
	ErrAccountLocked = errors.New("sulis: account locked")

	// ErrCSRFTokenInvalid is returned by VerifyCSRFToken (and so by the
	// RequireCSRFToken middleware built on it) when the double-submit CSRF
	// cookie is missing, the client echoed nothing back in the header or
	// form field, or the two values don't match. It deliberately doesn't
	// distinguish those cases: telling an attacker which one failed would
	// hand back a bit of information about a cookie they can't otherwise
	// read.
	ErrCSRFTokenInvalid = errors.New("sulis: csrf token invalid")
)

Functions

func IssueCSRFToken

func IssueCSRFToken() (token string, cookie *http.Cookie, err error)

IssueCSRFToken generates a new random CSRF token for the double-submit pattern described above and returns both the raw value — embed it in a hidden form field, or hand it to a same-origin script that will set CSRFHeaderName itself on the requests it makes — and the cookie to set alongside it (http.SetCookie(w, cookie)).

Call it once per session (right after SessionCookie, at login, is the natural place) or once per page/form render; either works, since VerifyCSRFToken only ever compares against whatever value is currently in the cookie, not anything remembered server-side.

func RequireCSRFToken

func RequireCSRFToken(next http.Handler) http.Handler

RequireCSRFToken returns middleware enforcing the double-submit check (see VerifyCSRFToken) on every state-changing request — any method other than GET/HEAD/OPTIONS; safe methods pass through untouched, same as RequireSameOrigin.

Apply this to routes reachable via a cookie-authenticated session; a route reachable only via an Authorization: Bearer header (see WithTokenSource(TokenSourceBearerOnly)) gains nothing from it. It emits no security event: a package-level function has no Sulis and so no configured EventSink to emit to. Use the identically-behaved (*Sulis).RequireCSRFToken method instead if you want rejections to reach your sink as EventCSRFRejected.

func RequireSameOrigin

func RequireSameOrigin(allowed []string) func(http.Handler) http.Handler

RequireSameOrigin returns middleware that rejects a cross-site, state-changing request (any method other than GET/HEAD/OPTIONS) using the Fetch Metadata Sec-Fetch-Site header, falling back to Origin when Sec-Fetch-Site is absent. allowed lists origins — scheme://host[:port], e.g. "https://app.example.com" — that are trusted even when the browser reports (or Origin implies) a cross-site request; include every origin your own frontend is actually served from if it differs from the API's origin.

This is a CSRF defense for cookie-authenticated routes; apply it (and/or RequireCSRFToken) to any route reachable via a cookie-sourced session. It costs nothing extra on a Bearer-only route, but such a route gains nothing from it either — see the README's "Cookie sessions and CSRF" section.

Decision on missing headers (recorded in PROGRESS.md's T507 Decisions): when BOTH Sec-Fetch-Site and Origin are absent, the request is allowed through. Every browser new enough to send either header will send at least one of them on a cross-site request; a request with neither is the signature of a non-browser client — a Bearer-token API caller, in particular, which is not CSRF-exploitable in the first place, since a browser never attaches a Bearer header to a request on its own. Rejecting on absence would block exactly that population for no CSRF benefit. The residual gap this leaves — a pre-Fetch-Metadata browser that also omits Origin on some cross-site state-changing request — is the reason RequireCSRFToken exists as defense in depth: it does not depend on either header at all. It emits no security event: a package-level function has no Sulis and so no configured EventSink to emit to. Use the identically-behaved (*Sulis).RequireSameOrigin method instead if you want rejections to reach your sink as EventSameOriginRejected.

func VerifyCSRFToken

func VerifyCSRFToken(r *http.Request) error

VerifyCSRFToken implements the double-submit comparison at the heart of RequireCSRFToken: the value in the CSRFCookieName cookie must be present and must match, byte for byte, whatever the client echoed back — checked first in the CSRFHeaderName header, then (for a traditional <form> POST that can't set a custom header) the CSRFFormField form value. A missing cookie, a missing echoed value, and a mismatch all return the same ErrCSRFTokenInvalid.

This check alone is a pure double-submit — not bound to the session, only to whoever can read this cookie — see the package doc comment above for why that's layered with the __Host- prefix and RequireSameOrigin rather than relied on in isolation.

The comparison is constant-time (crypto/subtle.ConstantTimeCompare), so a timing side channel can't be used to recover the token byte by byte. This is asserted by TestVerifyCSRFTokenUsesConstantTimeCompare (csrf_test.go) via implementation inspection — it greps this file's source for the subtle.ConstantTimeCompare call — rather than by timing the comparison directly: a real timing test is inherently flaky on a shared CI runner, and would either flake occasionally or need enough slack to stop actually testing anything. The mutation this guards against: replacing the call below with a data-dependent comparison (cookie.Value == sent, or bytes.Equal) still passes every functional test above but fails this one, which is the point.

FormValue parses the request body when its Content-Type is application/x-www-form-urlencoded or multipart/form-data (and only then — see net/http's ParseForm), so calling this before a JSON handler reads r.Body is safe; calling it before a form handler reads r.Body directly is not, for the same reason any Go form-handling code already has to call ParseForm before touching the raw body once.

Types

type Argon2Params

type Argon2Params struct {
	Memory      uint32 // memory in KiB (default: 64 * 1024)
	Iterations  uint32 // time parameter (default: 3)
	Parallelism uint8  // threads (default: 2)
	SaltLength  uint32 // bytes (default: 16)
	KeyLength   uint32 // bytes (default: 32)
}

Argon2Params holds the parameters for argon2id password hashing.

type AuthMethod

type AuthMethod string

AuthMethod names the credential that authenticated a session.

const (
	AuthMethodPassword     AuthMethod = "password"
	AuthMethodMagicLink    AuthMethod = "magic_link"
	AuthMethodPasskey      AuthMethod = "passkey"
	AuthMethodTwoFactor    AuthMethod = "two_factor"
	AuthMethodRecoveryCode AuthMethod = "recovery_code"
)

type Authentication

type Authentication struct {
	// contains filtered or unexported fields
}

Authentication is opaque proof that a user has completed authentication — every factor sulis itself verified, not merely a caller's say-so. Its fields are unexported and there is no exported constructor that takes a bare user ID, so nothing outside this package can produce a valid value.

The zero value carries no user ID. IssueSession rejects it (and any other invalid Authentication) with ErrNotAuthenticated rather than treating an empty user ID as real, so a forgotten or zeroed proof fails loudly instead of silently minting a session for whichever account that empty string happens to resolve to.

completeFirstFactor mints one internally when a first factor is verified and no second factor is enrolled; CompleteTwoFactor mints one once the second factor is verified too. Neither currently routes through IssueSession itself — both already hold the *User in hand and call createSession/issueSessionForUser directly, avoiding the redundant store round trip IssueSession's user-ID-only input would otherwise force — but the type exists so that, in code rather than only in a doc comment, "this user is authenticated" is a value only this package can produce.

type Budget

type Budget struct {
	Burst    int
	Interval time.Duration
}

Budget describes how many attempts a key may make and how fast the allowance refills. Burst is both the bucket size and the number of attempts available after a long idle period; one token is restored every Interval.

type Config

type Config struct {
	SessionDuration time.Duration // how long sessions are valid (default: 24h)
	TokenDuration   time.Duration // how long password reset tokens are valid (default: 1h)
	// MagicLinkDuration is how long magic-link tokens are valid (default:
	// 15m), independent of TokenDuration — see WithMagicLinkDuration.
	MagicLinkDuration              time.Duration
	TwoFactorTokenDuration         time.Duration // how long two-factor pending-login tokens are valid (default: 5m)
	EmailVerificationTokenDuration time.Duration // how long email verification tokens are valid (default: 24h)
	SessionTokenBytes              int           // length of random session tokens in bytes (default: 32)
	ResetTokenBytes                int           // length of random reset/magic link tokens in bytes (default: 32)
	RevokeSessionsOnPasswordChange bool          // revoke all sessions when a password is changed or reset (default: true)
	RequireVerifiedEmail           bool          // block new sessions for unverified accounts (default: true)
	// MagicLinkBinding requires RedeemMagicLink to be called with the
	// bindingNonce CreateMagicLinkToken returned alongside the token
	// (default: true) — see WithMagicLinkBinding.
	MagicLinkBinding  bool
	MinPasswordLength int // minimum accepted password length in bytes (default: 12)
	MaxPasswordLength int // maximum accepted password length in bytes (default: 1024)
	Argon2            Argon2Params
	Limiter           Limiter         // rate limiter consulted at guessable choke points (default: an in-process MemoryLimiter)
	PasswordChecker   PasswordChecker // screens new passwords for known-compromised values (default: passwordcheck.NewBlocklist())

	// Pepper is mixed into every password via HMAC-SHA256 before Argon2 —
	// see WithPepper. Default: nil, meaning no pepper.
	Pepper []byte

	// FailureLockoutThreshold, FailureLockoutBaseBackoff, and
	// FailureLockoutMaxBackoff configure the optional automatic-lockout
	// mechanism (see WithFailureLockout). FailureLockoutThreshold of 0
	// (the default) disables it entirely: VerifyPassword never writes
	// FailedLoginAttempts or LockedUntil.
	FailureLockoutThreshold   int
	FailureLockoutBaseBackoff time.Duration
	FailureLockoutMaxBackoff  time.Duration

	// IdleTimeout, if positive, is how long a session may go unused before
	// ValidateSession rejects it with ErrSessionExpired — independent of,
	// and typically much shorter than, SessionDuration. Zero (the default)
	// disables idle expiry entirely: sessions live until SessionDuration
	// regardless of use. See WithIdleTimeout.
	IdleTimeout time.Duration

	// CookieName is the name Authenticate reads the session token from
	// (when TokenSource permits a cookie) and SessionCookie/
	// ClearSessionCookie set (default: "__Host-session"). See
	// WithCookieName.
	CookieName string

	// TokenSource controls which channel(s) Authenticate accepts a session
	// token from (default: TokenSourceBoth). See WithTokenSource.
	TokenSource TokenSource

	// EventSink receives security events — every security-relevant
	// decision this package makes. Default: nil, meaning nothing is
	// emitted. See WithEventSink and events.go.
	EventSink EventSink
}

Config holds the configuration for a Sulis instance.

type Event

type Event struct {
	// Kind is which decision this is. Always set.
	Kind EventKind

	// UserID is the account the decision concerns, when one is known.
	// Empty for decisions made before an account is identified (a login for
	// an unknown address, a magic link for an address with no account yet,
	// a rejected pending token) and for the HTTP middleware rejections,
	// which happen before any session is validated.
	UserID string

	// SessionID is the session the decision concerns, when one is
	// relevant: the session issued, revoked, refreshed, expired, or
	// re-authenticated. Empty otherwise. It is the session's row ID, never
	// its token or the hash of its token.
	SessionID string

	// RequestInfo is what the calling application reported about the
	// request, passed straight through from the flow's own RequestInfo
	// argument. Flows that take no RequestInfo leave it zero. The one
	// exception is the HTTP middleware ((*Sulis).RequireCSRFToken and
	// (*Sulis).RequireSameOrigin), which has an *http.Request in hand and
	// fills in the transport peer address and User-Agent itself — see
	// requestInfoFromRequest for why that address is the direct peer and
	// not an X-Forwarded-For-resolved client.
	RequestInfo RequestInfo

	// At is when the decision was made, stamped at emission.
	At time.Time

	// Metadata carries the narrow, fixed labels listed under MetadataKey —
	// a reason, an auth method, a scope, a dimension. Nil when the kind
	// says everything there is to say. It is never a place to put payloads,
	// caller input, or error text.
	Metadata map[MetadataKey]string
}

Event is one security-relevant decision, as reported to an EventSink.

Every field is either an identifier this package generated, a timestamp, a RequestInfo the caller explicitly supplied, or a label drawn from the closed sets above.

The no-secrets rule

No event ever carries credential material. There is deliberately no field on Event that could hold one: no token, no password, no hash, no nonce. Beyond that, this package never copies ANY caller-supplied string into an event except the RequestInfo the caller explicitly passed for this purpose. In particular an event never carries:

  • a raw password, session token, reset/magic-link/two-factor/email token, or magic-link binding nonce;
  • a stored password hash or session token hash;
  • the submitted email address (people type passwords into the email field, and an event taxonomy that copies caller input is one bad day away from being a credential log);
  • the operator-supplied reason passed to DisableUser, for the same reason.

Accounts are identified by UserID, sessions by SessionID. Both are opaque identifiers this package generated; neither authenticates anything on its own (see SessionStore.DeleteSession for why knowing a session ID is not enough to act on it). The rule is enforced by test, not only by convention — see TestNoEventCarriesSecretMaterial in events_test.go, which drives every emitting flow and scans every field of every emitted event for every secret those flows were fed.

type EventKind

type EventKind string

EventKind names one security-relevant decision. The values are stable, lowercase, dot-namespaced strings safe to use as log field values, metric labels, or database enum entries.

Each constant repeats the EventKind type deliberately, rather than leaning on a const block carrying the type down the list: the completeness test (TestEveryDeclaredEventKindIsEmitted) reads them out of this file's source, so every declaration has to look the same.

const (
	// EventAccountRegistered reports that Register created an account.
	EventAccountRegistered EventKind = "account.registered"

	// EventLoginSucceeded reports that a password verified — VerifyPassword
	// completed, including its account-status and lockout checks. It does
	// NOT mean a session exists: a user with an enrolled second factor gets
	// EventSecondFactorDemanded next, and only EventSessionIssued means a
	// session was actually minted. Carries MetaMethod.
	EventLoginSucceeded EventKind = "login.succeeded"

	// EventLoginFailed reports that an authentication attempt was refused —
	// a wrong or missing credential, or a gate (disabled, locked,
	// unverified email, an unavailable second-factor checker) refusing an
	// otherwise-correct one. Carries MetaReason and MetaMethod. UserID is
	// empty when the address matched no account.
	EventLoginFailed EventKind = "login.failed"

	// EventPasswordChanged reports a successful ChangePassword.
	EventPasswordChanged EventKind = "password.changed"

	// EventPasswordSet reports a successful SetInitialPassword — a
	// previously passwordless account gaining its first password.
	EventPasswordSet EventKind = "password.set"

	// EventPasswordResetRequested reports that CreatePasswordResetToken (or
	// CreatePasswordResetTokenStrict) issued a reset token. The
	// unknown-address branch emits nothing: it changes no state, and an
	// event there would be a server-side record of addresses that do not
	// exist. Reset flooding is visible through EventRateLimitTripped on the
	// "reset" scope instead.
	EventPasswordResetRequested EventKind = "password.reset_requested"

	// EventPasswordReset reports a successful ResetPassword.
	EventPasswordReset EventKind = "password.reset"

	// EventPasswordRehashed reports that a stored hash was upgraded on a
	// successful verification — because it was weaker than the configured
	// Argon2Params, or because it predated NFKC normalization. This is what
	// makes "did raising Argon2Params actually reach the installed base?"
	// an answerable question.
	EventPasswordRehashed EventKind = "password.rehashed"

	// EventPasswordRehashFailed reports that such an upgrade was attempted
	// and did not land. The login itself succeeded regardless — the upgrade
	// is best effort and its failure is deliberately swallowed (see
	// rehashPassword) — so this event is the only trace it left. Carries
	// MetaReason: ReasonHashFailed, ReasonStoreFailed, or
	// ReasonPasswordChanged.
	EventPasswordRehashFailed EventKind = "password.rehash_failed"

	// EventPasswordLegacyFormMatched reports that a password verified only
	// through verifyPassword's pre-NFKC compatibility fallback: the stored
	// hash was written before normalization existed. It is followed by an
	// EventPasswordRehashed (or EventPasswordRehashFailed) for the same
	// account, because matching that way is exactly the moment to migrate
	// the hash.
	//
	// This event is what makes retiring that fallback answerable: when it
	// stops appearing for a deployment, every account has been migrated and
	// the fallback can go. Without it the fallback would have to stay
	// forever on the grounds that nobody can prove it is unused.
	EventPasswordLegacyFormMatched EventKind = "password.legacy_form_matched"

	// EventSecondFactorDemanded reports that a verified first factor earned
	// a pending token rather than a session, because the account has a
	// second factor enrolled. Also emitted by CreateTwoFactorToken.
	EventSecondFactorDemanded EventKind = "twofactor.demanded"

	// EventSecondFactorCompleted reports a successful CompleteTwoFactor.
	EventSecondFactorCompleted EventKind = "twofactor.completed"

	// EventSecondFactorFailed reports that CompleteTwoFactor refused —
	// an unknown, expired, already-used, or wrong-purpose pending token, a
	// token belonging to a different user, or a gate refusing the account.
	// Carries MetaReason.
	EventSecondFactorFailed EventKind = "twofactor.failed"

	// EventSessionIssued reports that a session row was created, by any
	// path: Register, Login, a redeemed magic link, CompleteTwoFactor,
	// IssueSession, or IssueSessionUnchecked. Carries MetaMethod and the
	// new session's SessionID. This is the method-agnostic "somebody is now
	// signed in" signal.
	EventSessionIssued EventKind = "session.issued"

	// EventSessionRevoked reports a successful RevokeSession (MetaScope
	// ScopeSingleSession, with SessionID set) or RevokeAllSessions
	// (MetaScope ScopeAllSessions, with SessionID empty).
	EventSessionRevoked EventKind = "session.revoked"

	// EventSessionRefreshed reports a successful RefreshSession. SessionID
	// is the NEW session's ID — RefreshSession mints a new row rather than
	// rewriting the old one.
	EventSessionRefreshed EventKind = "session.refreshed"

	// EventSessionExpired reports that ValidateSession rejected and deleted
	// a session past its absolute ExpiresAt.
	EventSessionExpired EventKind = "session.expired"

	// EventSessionIdleExpired reports that ValidateSession rejected and
	// deleted a session past its IdleExpiresAt — the idle timeout
	// configured by WithIdleTimeout, checked before absolute expiry.
	EventSessionIdleExpired EventKind = "session.idle_expired"

	// EventEmailChangeStaged reports that ChangeEmail staged a new address
	// and issued a confirmation token. The address itself is not in the
	// event; see Event's doc comment for the no-secrets rule.
	EventEmailChangeStaged EventKind = "email.change_staged"

	// EventEmailChangeConfirmed reports that ConfirmEmailChange made a
	// staged address live, revoking the account's sessions in the process.
	EventEmailChangeConfirmed EventKind = "email.change_confirmed"

	// EventEmailVerified reports that an address was verified for the first
	// time, by VerifyEmail or by a redeemed magic link. The idempotent
	// re-verification of an already-verified address emits nothing, because
	// nothing was decided.
	EventEmailVerified EventKind = "email.verified"

	// EventMagicLinkCreated reports that CreateMagicLinkToken issued a
	// link. UserID is empty when the address has no account yet — the user
	// is created at redemption.
	EventMagicLinkCreated EventKind = "magiclink.created"

	// EventMagicLinkRedeemed reports that a magic-link token was consumed
	// and, when binding is enabled, matched its binding nonce. It is the
	// magic-link counterpart of EventLoginSucceeded: proof of mailbox
	// control, not proof that a session followed.
	EventMagicLinkRedeemed EventKind = "magiclink.redeemed"

	// EventMagicLinkRejected reports that RedeemMagicLink refused — an
	// unknown, expired or already-used token, or a missing or wrong binding
	// nonce. Carries MetaReason. A ReasonBindingMismatch here is the
	// signal that a link was clicked somewhere other than the browser that
	// asked for it: forwarded, prefetched, or stolen.
	EventMagicLinkRejected EventKind = "magiclink.rejected"

	// EventRateLimitTripped reports that the configured Limiter denied a
	// key. Carries MetaScope (the choke point: "password", "reset",
	// "magic") and MetaDimension (DimensionAccount or DimensionIP). The
	// limiter key itself is never in the event — it embeds an email
	// address.
	EventRateLimitTripped EventKind = "ratelimit.tripped"

	// EventAccountDisabled reports a successful DisableUser. The
	// operator-supplied reason is deliberately not carried.
	EventAccountDisabled EventKind = "account.disabled"

	// EventAccountEnabled reports a successful EnableUser.
	EventAccountEnabled EventKind = "account.enabled"

	// EventAccountLocked reports that the optional automatic lockout (see
	// WithFailureLockout) set or extended a LockedUntil deadline after a
	// failed password attempt.
	EventAccountLocked EventKind = "account.locked"

	// EventAccountLockoutCleared reports that a correct password outside
	// any active lockout window cleared the stale failure count and
	// deadline.
	EventAccountLockoutCleared EventKind = "account.lockout_cleared"

	// EventReauthSucceeded reports a successful ReAuthenticate — the
	// step-up gate RequireRecentAuth checks was refreshed.
	EventReauthSucceeded EventKind = "reauth.succeeded"

	// EventReauthFailed reports that ReAuthenticate refused. Carries
	// MetaReason. A burst of these against one session is a stolen-cookie
	// signal: whoever holds the session does not know the password.
	EventReauthFailed EventKind = "reauth.failed"

	// EventCSRFRejected reports that (*Sulis).RequireCSRFToken's
	// double-submit check refused a state-changing request. Emitted only by
	// the Sulis-bound middleware; the package-level RequireCSRFToken has no
	// sink to emit to.
	EventCSRFRejected EventKind = "csrf.rejected"

	// EventSameOriginRejected reports that (*Sulis).RequireSameOrigin
	// refused a state-changing request as cross-site (ReasonCrossSite, from
	// Sec-Fetch-Site) or as carrying an unlisted Origin
	// (ReasonOriginNotAllowed). Emitted only by the Sulis-bound middleware;
	// the package-level RequireSameOrigin has no sink to emit to.
	EventSameOriginRejected EventKind = "sameorigin.rejected"
)

type EventSink

type EventSink interface {
	Emit(ctx context.Context, e Event)
}

EventSink receives security events.

Emit returns nothing on purpose: a sink has no way to fail a flow, so there is no error for this package to propagate and no temptation to propagate one. Implementations must be safe for concurrent use — Emit is called from whatever goroutine is running the flow — and should return quickly, doing anything slow or fallible elsewhere. Emit is called AFTER the decision it reports.

A panicking Emit is recovered, and the panic dropped, so a broken sink cannot take authentication down with it — but that containment is itself silent: there is nowhere left to report the panic to, so a sink that panics gets no error, no log line, and no second call this time around. Do not rely on it. A sink should hand the event off — a channel, a logger, a buffer — and return, rather than doing anything slow or failure-prone inline.

func NewSlogSink

func NewSlogSink(logger *slog.Logger) EventSink

NewSlogSink adapts a *slog.Logger to EventSink, so wiring security events into an application that already logs structurally is one line:

sulis.WithEventSink(sulis.NewSlogSink(logger))

Every event is logged at slog.LevelInfo with the message "sulis security event" and one attribute per populated field: kind, user_id, session_id, ip, user_agent, at, and one per Metadata entry (reason, method, scope, dimension). Empty fields are omitted rather than logged as "". Metadata attributes are emitted in sorted key order, so two events of the same kind produce the same attribute order.

A nil logger falls back to slog.Default rather than panicking on the first event — a forgotten logger should be a misconfiguration, not an outage.

type Limiter

type Limiter interface {
	Allow(ctx context.Context, key string) error
}

Limiter enforces a rate limit for a caller-supplied key. Implementations decide the algorithm, window, and storage (e.g. a token bucket backed by Redis or an in-memory store). Allow returns a non-nil error if the key should be denied.

type LoginResult

type LoginResult struct {
	User              *User
	Session           *Session
	SessionToken      string
	NeedsSecondFactor bool
	PendingToken      string
}

LoginResult is the outcome of a successful first factor.

Exactly one outcome is populated. When NeedsSecondFactor is true, Session and SessionToken are empty and PendingToken holds a short-lived, single-use token to pass to CompleteTwoFactor once the application has verified the second factor. Otherwise Session and SessionToken hold a live session and its raw token, and PendingToken is empty.

Callers must branch on NeedsSecondFactor. Treating a non-nil LoginResult as "logged in" defeats two-factor authentication.

type MemoryLimiter

type MemoryLimiter struct {
	// contains filtered or unexported fields
}

MemoryLimiter is a per-process token-bucket Limiter. It is the default, so that a Sulis built with no options still resists guessing — a library whose documentation has to ask for rate limiting is a library that mostly runs without it.

It is per-process: with several instances behind a load balancer, each enforces its own budget. Replace it with a shared implementation (Redis or similar) via WithLimiter for a multi-instance deployment.

A single MemoryLimiter satisfies sulis.Limiter, totp.Limiter and recovery.Limiter, which are structurally identical, so one instance can guard all three packages. That identity is compiler-enforced rather than hoped for: see the assignability declarations at the top of limiter_test.go.

func NewMemoryLimiter

func NewMemoryLimiter(opts ...MemoryLimiterOption) *MemoryLimiter

NewMemoryLimiter creates a token-bucket limiter with the default budgets.

func (*MemoryLimiter) Allow

func (l *MemoryLimiter) Allow(_ context.Context, key string) error

Allow consumes one token for key, returning ErrRateLimited when the key's bucket is empty.

type MemoryLimiterOption

type MemoryLimiterOption func(*MemoryLimiter)

MemoryLimiterOption configures a MemoryLimiter.

func WithBudget

func WithBudget(prefix string, b Budget) MemoryLimiterOption

WithBudget sets the budget for keys carrying the given prefix. The longest matching prefix wins.

func WithMaxTrackedKeys

func WithMaxTrackedKeys(n int) MemoryLimiterOption

WithMaxTrackedKeys bounds how many distinct keys are held in memory. A limiter that can be driven out of memory is a denial of service rather than a defence, so tracking is capped and the least recently used keys are dropped once the cap is reached.

type MetadataKey

type MetadataKey string

MetadataKey is a key in Event.Metadata. The set is closed — these four constants are the only keys this package ever writes — so a sink can index on them without pattern-matching free-form strings, and a reviewer can see at a glance everything an event can say beyond its kind.

const (
	// MetaReason says why a decision went the way it did. Its value is
	// always one of the Reason constants below: a fixed label chosen by
	// this package, never caller input and never an error string.
	MetaReason MetadataKey = "reason"

	// MetaMethod is an AuthMethod value — which credential is involved.
	MetaMethod MetadataKey = "method"

	// MetaScope narrows the kind. On EventSessionRevoked it is
	// ScopeSingleSession or ScopeAllSessions; on EventRateLimitTripped it
	// is the choke point whose budget was exhausted ("password", "reset",
	// "magic").
	MetaScope MetadataKey = "scope"

	// MetaDimension is DimensionAccount or DimensionIP, on
	// EventRateLimitTripped: which of the limiter's two keys denied. The
	// distinction is the whole reason both keys exist — one account being
	// guessed is a different incident from one host spraying many
	// accounts.
	MetaDimension MetadataKey = "dimension"
)

type NoSecondFactors

type NoSecondFactors struct{}

NoSecondFactors is an explicit declaration that an application has no second factors at all. Prefer it over a hand-written stub, so the intent is greppable.

func (NoSecondFactors) HasSecondFactor

func (NoSecondFactors) HasSecondFactor(context.Context, string) (bool, error)

HasSecondFactor always reports false.

type Option

type Option func(*Config)

Option is a functional option for configuring Sulis.

func WithArgon2Params

func WithArgon2Params(p Argon2Params) Option

WithArgon2Params sets custom argon2id parameters for password hashing.

func WithCookieName

func WithCookieName(name string) Option

WithCookieName overrides the session cookie's name (default: "__Host-session"). New rejects a name that isn't a valid HTTP cookie token (empty, or containing whitespace/control/separator characters).

Choosing a name without the "__Host-" prefix is a valid, explicit opt-out of that browser-enforced guarantee (see defaultCookieName) — do this only if you have a concrete reason to (for instance, sharing the cookie across subdomains via an explicit Domain your own reverse proxy adds, which this package's cookies never set themselves). Secure, Path=/, and HttpOnly are set on SessionCookie/ClearSessionCookie regardless of name: nothing in this package's configuration surface can turn them off.

func WithEmailVerificationTokenDuration

func WithEmailVerificationTokenDuration(d time.Duration) Option

WithEmailVerificationTokenDuration sets how long email verification tokens remain valid.

func WithEventSink

func WithEventSink(sink EventSink) Option

WithEventSink routes security events to sink. Every security-relevant decision this package makes — a password refused, a second factor demanded, a session issued or expired, a limiter tripped, an account disabled, and more — is reported to it. See EventKind's constants for the full taxonomy and Event's doc comment for what a reported event may and may not contain.

The default is nil: no sink, no events, and nothing on any flow's hot path but a nil check. That is not just a description of the default — arguments are evaluated before a call, so an event's Metadata map is built only after the nil-sink check inside emit, never at the call site where it would be allocated on every decision whether or not anybody was listening. TestNilSinkPathAllocatesNothing (events_test.go) holds that guarantee to account with testing.AllocsPerRun.

The one-line wiring for an application that already has a *slog.Logger:

auth, err := sulis.New(users, sessions, tokens, factors,
    sulis.WithEventSink(sulis.NewSlogSink(logger)))

func WithFailureLockout

func WithFailureLockout(threshold int, baseBackoff, maxBackoff time.Duration) Option

WithFailureLockout enables automatic, temporary lockout after threshold consecutive wrong passwords for one account. Once threshold is reached, VerifyPassword sets User.LockedUntil to baseBackoff after the moment of the triggering failure; every further wrong password while still locked pushes LockedUntil out again, doubling the backoff each time, up to maxBackoff. The lockout — and the failure count behind it — clears itself automatically the next time a correct password verifies outside the window, OR the account's password is successfully changed or reset (ChangePassword, ResetPassword, SetInitialPassword) — proving control of the account well enough to set a new password is at least as strong an identity proof as the login password itself. There is no explicit unlock call for either path. DisableUser/EnableUser remain available for an operator-initiated block, which is a distinct, unrelated mechanism that none of the above clears — a password reset lifts an automatic lockout, never a manual disable; only EnableUser does that (see the README's "Account disable and lockout" section).

Default: disabled (threshold 0), so a Sulis built with no options never writes FailedLoginAttempts or LockedUntil, and VerifyPassword's normal path pays no extra store round trip.

Off by default deliberately: this locks out the legitimate account owner exactly as effectively as it locks out an attacker, so an attacker who merely knows (or guesses) an email address can weaponize it as a denial-of-service against that account — a failure mode the rate limiter (on by default; see WithLimiter/MemoryLimiter) does not share, since it throttles the guesser without touching the account's own ability to log in once its window passes. Enable this only if your threat model needs an escalating response beyond rate limiting, and prefer a long baseBackoff/ maxBackoff pair over a short one: the whole point is to make continued guessing expensive without approaching a permanent lock a legitimate owner could not eventually recover from on their own.

func WithIdleTimeout

func WithIdleTimeout(d time.Duration) Option

WithIdleTimeout enables idle expiry: a session unused for longer than d is rejected by ValidateSession with ErrSessionExpired, even if its absolute SessionDuration lifetime has not yet elapsed. "Unused" is tracked via Session.LastSeenAt/IdleExpiresAt, refreshed by ValidateSession on a throttled cadence (see sessionTouchInterval in session.go) rather than on every single call — the idle deadline can therefore lag true last-use by up to that interval, which trades a small amount of precision for not writing to the session store on every authenticated request.

Passing d <= 0 disables idle expiry — the default, so a Sulis built with no options never checks or writes IdleExpiresAt at all.

func WithLimiter

func WithLimiter(l Limiter) Option

WithLimiter replaces the rate limiter consulted at guessable authentication choke points: password verification, and password reset / magic link token issuance. The default is an in-process MemoryLimiter; supply a shared implementation (Redis or similar) when running more than one instance, since the default enforces its budget per process.

Passing nil disables rate limiting, but prefer WithoutRateLimiting, which says so in code.

func WithMagicLinkBinding

func WithMagicLinkBinding(b bool) Option

WithMagicLinkBinding controls whether redeeming a magic link requires a binding nonce matching the one CreateMagicLinkToken generated alongside the token (default: true).

CreateMagicLinkToken returns (token, bindingNonce string, err error). The application is expected to set bindingNonce as a short-lived, HttpOnly cookie on the response to the request that triggered issuance — NOT to embed it in the emailed link itself, which would defeat the entire point — and to read it back from that cookie when the link is later clicked, passing it to RedeemMagicLink alongside the token recovered from the link's query string. Because the nonce travels only in a cookie scoped to the browser that requested the link, a copy of the link forwarded to, or opened by, a different device or browser arrives without the matching cookie: RedeemMagicLink then rejects it with ErrTokenInvalid even though the token itself is still valid, unused, and unexpired. That is what makes a forwarded magic link useless to whoever it was forwarded to.

The nonce is stored hashed (SHA-256, alongside the token's own hash — see Token.NonceHash), never in plaintext, and compared at redemption via crypto/subtle.ConstantTimeCompare over the hashes, exactly as VerifyCSRFToken compares its own double-submit token.

Passing false accepts any bindingNonce value at redemption — including "" — because CreateMagicLinkToken stops generating one at all: it returns bindingNonce == "" and the created Token carries no NonceHash for RedeemMagicLink to check against. The trade-off: without binding, a magic link works from whatever device or browser opens it, which is convenient when mail is routinely read somewhere other than where the link was requested (a common case — requesting from a desktop, opening from a phone's mail app) — but it also means a link forwarded to someone else, or consumed by an automated mail scanner that prefetches links before a human ever clicks, signs that other party or scanner in instead. Turning this off is a deliberate, greppable trade-off; make it with that risk in mind, not by leaving it at the default without thinking about it. See the README's magic-link section for the prefetch hazard and why a confirmation click (rather than a bare GET link) is recommended regardless of this setting.

func WithMagicLinkDuration

func WithMagicLinkDuration(d time.Duration) Option

WithMagicLinkDuration sets how long magic-link tokens remain valid (default: 15m). This is independent of TokenDuration/WithTokenDuration, which governs password-reset tokens only: a magic link is a full credential delivered in cleartext over email — where it can be forwarded, scanned by a mail security appliance, or prefetched by a client before the recipient ever sees it — so it should live for only as long as a legitimate recipient plausibly needs to click it, not as long as a password-reset link a human reads and then types a new password after. The previous behavior, before this option existed, was both flows sharing TokenDuration (default 1h); a deployment relying on that 1h magic-link window must now set WithMagicLinkDuration(time.Hour) explicitly.

func WithPasswordChecker

func WithPasswordChecker(c PasswordChecker) Option

WithPasswordChecker replaces the checker consulted on every password-setting path — Register, ChangePassword, ResetPassword, SetInitialPassword — after the length policy passes and before the password is hashed. A checker that returns ErrPasswordCompromised rejects the password; any other error is an operational failure and propagates to the caller unchanged.

The default is passwordcheck.NewBlocklist(), an embedded corpus of the ten thousand most common passwords: no network, no third party, nothing to configure, and on by default because a check that has to be discovered in documentation mostly does not run. To also query Have I Been Pwned, compose rather than replace — passing the HIBP checker alone silently drops the local blocklist:

sulis.WithPasswordChecker(passwordcheck.All(
	passwordcheck.NewBlocklist(),
	passwordcheck.NewHIBP(),
))

Passing nil disables password checking entirely, which is the right call only when something outside sulis already screens passwords.

The checker is deliberately NOT consulted by VerifyPassword, Login, or ReAuthenticate. Screening at verification time would lock out every existing user whose password happens to be in the corpus the moment one is added or refreshed — turning a hardening change into a mass outage, and worse, one whose only remedy (a password reset) is itself a login-adjacent flow. A password is screened where it is chosen, not where it is proven. Applications that want existing users moved off a now-known-bad password should detect that out of band and require a change, which keeps the user in control of when it happens.

func WithPasswordLengthLimits

func WithPasswordLengthLimits(minLength, maxLength int) Option

WithPasswordLengthLimits sets the minimum and maximum accepted password length. Both bounds are measured in bytes (len(password)), not runes or characters — deliberately, to bound Argon2's input size regardless of encoding, so multi-byte UTF-8 passwords count for more than one unit per character.

The bytes counted are those of the NFKC-normalized password (see normalizePassword), because that is the string Argon2 actually consumes. Normalization can shorten a password — twelve fullwidth digits are 36 raw bytes and 12 normalized ones — so measuring the raw form would let a password through a minimum it does not actually meet.

The default minimum is 12. It was 8 before this series; NIST SP 800-63B treats 8 as the floor for a memorized secret and expects more from anything that is not backed by a second factor, and this series was already breaking the API. Lowering it is supported and sometimes right — a deployment where every account has a passkey or TOTP, for instance — and doing so makes the embedded blocklist (see WithPasswordChecker) matter far more, since most common passwords are shorter than 12 characters and are otherwise rejected by this policy before the checker ever sees them.

func WithPepper

func WithPepper(pepper []byte) Option

WithPepper sets a secret pepper mixed into every password via HMAC-SHA256 before Argon2 (see password.go's applyPepper). It protects against a database-only leak — a copy of the user table with no access to application config or secrets yields hashes nobody can run an offline dictionary attack against without also having the pepper. It does NOT protect against a full application compromise: the same process that hashes passwords holds the pepper, so an attacker who reaches that process reaches both.

Losing the pepper makes EVERY stored hash permanently unverifiable — there is no fallback, unlike a hash's own salt (which travels with the hash). Store it with the same care as a private key: outside version control, in a secrets manager or environment variable, never beside the database it is meant to protect.

The pepper is a first-deployment decision, not a knob to turn later. Setting one where there was none, changing its value, or clearing one that was set makes every hash written under the old configuration unverifiable: verifyPassword applies whichever pepper is CURRENTLY configured, uniformly, to both the NFKC and pre-NFKC forms its existing T505 legacy-fallback seam already tries (see the T505 Decisions row) — it does not also try "with each pepper this deployment has ever used" on top of that. Unlike T505's normalization fallback, which is safe to widen because it can only ever match the exact bytes a hash was already derived from, a pepper-introduced-later problem is symmetric with a pepper-changed or pepper-removed one: there is no single "old form" to fall back to, only an unbounded list of past values this library has no way to know. Introduce a pepper before the first password is ever hashed, or plan on resetting affected users' passwords when introducing one later — the same recovery path already used for a lost password, not a new failure mode.

func WithRequireVerifiedEmail

func WithRequireVerifiedEmail(require bool) Option

WithRequireVerifiedEmail sets whether new sessions are blocked until the account's email is verified. Register's signup session and magic-link redemption (which verifies the email itself) are always exempt.

func WithRevokeSessionsOnPasswordChange

func WithRevokeSessionsOnPasswordChange(revoke bool) Option

WithRevokeSessionsOnPasswordChange controls whether all of a user's sessions are revoked when their password is changed or reset (default: true).

func WithSessionDuration

func WithSessionDuration(d time.Duration) Option

WithSessionDuration sets how long sessions remain valid.

func WithTokenDuration

func WithTokenDuration(d time.Duration) Option

WithTokenDuration sets how long password reset tokens remain valid. Magic-link tokens do NOT use this — they have their own, independent duration; see WithMagicLinkDuration.

func WithTokenSource

func WithTokenSource(ts TokenSource) Option

WithTokenSource restricts which channel(s) Authenticate accepts a session token from (default: TokenSourceBoth). See TokenSource's own constants for what each value means and why TokenSourceBoth remains the default.

func WithTwoFactorTokenDuration

func WithTwoFactorTokenDuration(d time.Duration) Option

WithTwoFactorTokenDuration sets how long two-factor pending-login tokens remain valid.

func WithoutRateLimiting

func WithoutRateLimiting() Option

WithoutRateLimiting disables rate limiting entirely.

Rate limiting is on by default because a library that has to ask for it in its documentation mostly runs without it. Turning it off should therefore be a visible, greppable line in your code rather than the consequence of not writing one — for instance when an upstream gateway already enforces limits.

type PasswordChecker

type PasswordChecker interface {
	Check(ctx context.Context, password string) error
}

PasswordChecker screens a candidate password for known-compromised values, beyond what the length policy can judge. It is consulted on every path that sets a password — Register, ChangePassword, ResetPassword, SetInitialPassword — and never on a path that merely verifies one; see WithPasswordChecker.

Check receives the password in its NFKC-normalized form, which is exactly the string that will be hashed and stored. It returns nil if the password is acceptable, ErrPasswordCompromised (or an error wrapping it) to reject it, and any other error if it could not reach a verdict — that last case propagates to the caller unchanged and must not be presented to a user as "your password is compromised", because nobody actually looked.

Implementations must be safe for concurrent use. This is the same method set as passwordcheck.Checker, so the checkers in that package satisfy it directly and so does anything written against either interface.

type RequestInfo

type RequestInfo struct {
	IP        string
	UserAgent string
}

RequestInfo carries per-request caller context. It feeds the IP dimension of rate limiting and is recorded on sessions so users can recognise their own devices. The zero value is valid: callers with nothing to report pass RequestInfo{}.

type SecondFactorChecker

type SecondFactorChecker interface {
	HasSecondFactor(ctx context.Context, userID string) (bool, error)
}

SecondFactorChecker reports whether a user has an enrolled second factor.

It is a required argument to New rather than an option, because a default would silently answer "no" — and answering "no" by default is exactly the bypass this type exists to close. Applications that genuinely have no second factors pass NoSecondFactors{}, which says so in code rather than by omission.

Implementations should consult whatever the application treats as a second factor: a verified TOTP enrollment, a registered passkey, or both.

type Session

type Session struct {
	ID     string
	UserID string
	// TokenHash is the SHA-256 hash of the session token. The raw token is
	// never a field on this struct: it is returned beside the *Session at
	// issue time and nowhere else, so no store can persist it by accident.
	TokenHash string
	ExpiresAt time.Time
	CreatedAt time.Time
	// AuthenticatedAt is when the credential behind this session was last
	// proven — at issuance, and again on every successful ReAuthenticate.
	// RequireRecentAuth compares it against a caller-supplied maxAge to gate
	// security-sensitive operations (enrolling or replacing a second
	// factor, removing a passkey, disabling 2FA, changing email,
	// regenerating recovery codes — see the README) behind more than a
	// bare, possibly hours-old session. A session issued before this field
	// existed reads back as the zero time, which is always older than any
	// maxAge, so RequireRecentAuth fails closed on it rather than treating
	// an absent stamp as fresh.
	AuthenticatedAt time.Time
	// Method records which credential last authenticated this session —
	// set at issuance from the AuthMethod the caller vouches for (or, for
	// IssueSession, the one recorded on the Authentication proof) and left
	// untouched by ReAuthenticate, which refreshes AuthenticatedAt only.
	Method AuthMethod
	// LastSeenAt records when this session was last used — stamped at
	// issuance, and refreshed by ValidateSession via TouchSession while the
	// session stays active. It is throttled, not written on every call: see
	// sessionTouchInterval's doc comment for why. Useful for a
	// device-management "last active" column; do not read it as
	// precise-to-the-request.
	LastSeenAt time.Time
	// IdleExpiresAt is the deadline past which ValidateSession rejects this
	// session with ErrSessionExpired even though ExpiresAt has not been
	// reached yet — an idle-timeout, refreshed alongside LastSeenAt on the
	// same throttled cadence. Nil means idle expiry is disabled for this
	// session, which is the case for every session unless WithIdleTimeout
	// is configured (the default).
	IdleExpiresAt *time.Time
	// IP and UserAgent are copied from the RequestInfo the issuing call
	// received, so a "where you're signed in" screen can render something
	// recognizable ("Chrome on a Lisbon IP", roughly). Only the
	// issuance paths that take a RequestInfo populate them:
	// Register/Login/RedeemMagicLink/CompleteTwoFactor. IssueSession and
	// IssueSessionUnchecked have no RequestInfo in their Appendix A
	// signatures, so sessions minted through them carry the zero value —
	// see the PROGRESS.md Decisions row for T503.
	IP        string
	UserAgent string
	Metadata  map[string]any
}

Session represents a server-side authentication session.

func SessionFromContext

func SessionFromContext(ctx context.Context) (*Session, bool)

SessionFromContext retrieves the current session from the request context.

type SessionStore

type SessionStore interface {
	CreateSession(ctx context.Context, session *Session) error
	GetSessionByTokenHash(ctx context.Context, tokenHash string) (*Session, error)

	// ListUserSessions returns every session belonging to userID, in any
	// order. Matching nothing is not an error — an empty (possibly nil)
	// slice and a nil error.
	//
	// Returned sessions MUST be independent copies, the same no-aliasing
	// rule CreateSession/GetSessionByTokenHash already follow: a caller
	// mutating an entry in the returned slice must never reach the stored
	// row. This includes TokenHash — this method returns it exactly as
	// stored, the same as GetSessionByTokenHash does. Stripping it to ""
	// before it reaches an application is Sulis.ListUserSessions's job,
	// not this method's.
	ListUserSessions(ctx context.Context, userID string) ([]Session, error)

	// DeleteSession removes the session identified by id if it belongs to
	// userID. The membership check and the removal MUST happen as a
	// single atomic operation scoped to both columns:
	//
	//	DELETE FROM sessions WHERE id = ? AND user_id = ?
	//
	// Zero rows affected — whether id does not exist at all, or exists
	// but belongs to a different user — MUST return ErrSessionNotFound
	// rather than succeeding silently. This is what makes cross-user
	// revocation impossible through RevokeSession: it passes the
	// caller's own userID, so guessing or leaking another user's session
	// ID never deletes anything.
	DeleteSession(ctx context.Context, userID, id string) error

	DeleteUserSessions(ctx context.Context, userID string) error

	// DeleteUserSessionsExcept removes every session belonging to userID
	// except the one identified by keepSessionID, as a single operation:
	//
	//	DELETE FROM sessions WHERE user_id = ? AND id <> ?
	//
	// This is the "sign out everywhere else" primitive: a device-management
	// UI keeps the session the request making the call is itself using and
	// revokes the rest. keepSessionID naming a session that does not exist,
	// or one belonging to a different user, is not an error — every OTHER
	// session for userID is removed regardless, matching
	// DeleteUserSessions's "matching nothing is not an error" behavior for
	// the degenerate all-sessions case. There is no Sulis-level wrapper for
	// this method (see the PROGRESS.md Decisions row): Appendix A does not
	// name one, and the facade-level path for the same outcome is
	// ListUserSessions plus a RevokeSession per entry.
	DeleteUserSessionsExcept(ctx context.Context, userID, keepSessionID string) error

	CleanExpired(ctx context.Context) error

	// UpdateAuthenticatedAt stamps the session identified by id with at,
	// leaving every other field (including ExpiresAt and Method) untouched:
	//
	//	UPDATE sessions SET authenticated_at = ? WHERE id = ?
	//
	// Zero rows affected — id does not exist — MUST return
	// ErrSessionNotFound. This is the write path behind ReAuthenticate: it
	// refreshes how recently a session's owner last proved their
	// credential, without minting a new session or rotating its token, so
	// a subsequent RequireRecentAuth call passes immediately afterward.
	//
	// It is deliberately its own method rather than an extra parameter on
	// TouchSession's session-liveness "last seen" touch below: a step-up
	// re-authentication and a liveness heartbeat are different events with
	// different callers and different frequencies, and folding them into
	// one call would make a caller that means to refresh only one of the
	// two silently refresh both.
	UpdateAuthenticatedAt(ctx context.Context, id string, at time.Time) error

	// TouchSession stamps the session identified by id with a fresh
	// lastSeen and idleExpires, leaving every other column (ExpiresAt,
	// TokenHash, AuthenticatedAt, Method, IP, UserAgent, ...) untouched:
	//
	//	UPDATE sessions SET last_seen_at = ?, idle_expires_at = ? WHERE id = ?
	//
	// idleExpires is nil whenever idle expiry is disabled (WithIdleTimeout
	// not configured, the default). A nil idleExpires MUST be written as
	// SQL NULL, clearing any previously-stored value — an application that
	// enables idle expiry and later disables it again must not have a
	// stale deadline linger and silently start enforcing itself once more.
	//
	// Zero rows affected — id does not exist — MUST return
	// ErrSessionNotFound. This is the write path behind
	// Sulis.ValidateSession's liveness touch, and it is deliberately
	// throttled rather than called on every validation — see
	// sessionTouchInterval's doc comment for the cost rationale.
	TouchSession(ctx context.Context, id string, lastSeen time.Time, idleExpires *time.Time) error
}

SessionStore defines the persistence operations for sessions.

A store MUST NOT share mutable state with its callers in either direction. Metadata is a map, so copying a *Session with a plain struct assignment copies a map header rather than the map, leaving the caller holding a live handle on the stored session — and a session a caller can rewrite outside CreateSession is a session whose UserID a caller can rewrite. Copy the map (one level is enough) when storing a session and when returning one. Stores that reconstruct rows from a database read get this for free; in-memory ones do not. storetest.RunSessionStore checks it.

type Sulis

type Sulis struct {
	// contains filtered or unexported fields
}

Sulis is the main authentication service. It coordinates user registration, login, password reset, and session management.

func New

func New(users UserStore, sessions SessionStore, tokens TokenStore, factors SecondFactorChecker, opts ...Option) (*Sulis, error)

New creates a new Sulis instance with the given stores and options.

factors is required and must not be nil: it is how the library learns that a user has a second factor, and defaulting it would mean silently issuing fully-privileged sessions to accounts that expect two-factor authentication. Applications with no second factors pass NoSecondFactors{}.

func (*Sulis) Authenticate

func (s *Sulis) Authenticate(next http.Handler) http.Handler

Authenticate returns HTTP middleware that validates the session token from the channel(s) selected by the configured TokenSource (default TokenSourceBoth: either an Authorization: Bearer header or the configured session cookie — see WithTokenSource and WithCookieName). On success, the User and Session are attached to the request context and can be retrieved with UserFromContext and SessionFromContext. On failure, the middleware responds with 401 Unauthorized, carrying WWW-Authenticate and Cache-Control: no-store (see writeUnauthorized).

func (*Sulis) ChangeEmail

func (s *Sulis) ChangeEmail(ctx context.Context, userID, newEmail string) (string, error)

ChangeEmail stages newEmail as the account's pending address and returns a raw, single-use token proving intent to claim it. The live Email and EmailVerifiedAt are untouched: they change only when the returned token is later redeemed via ConfirmEmailChange. Staging a second address before the first is confirmed supersedes it — the earlier token is invalidated (see ConfirmEmailChange).

Returns ErrInvalidEmail for a malformed address, and ErrUserAlreadyExists if newEmail is already the live address of any account, including this one — there is nothing to prove and nothing to change in that case.

The raw token is returned once for the caller to deliver to the NEW address; sulis does not send mail. Callers MUST also notify the OLD address that a change has been requested — that notification, sent to an address the attacker does not control, is how a victim catches an account takeover while the pending change can still be undone.

func (*Sulis) ChangePassword

func (s *Sulis) ChangePassword(ctx context.Context, userID, oldPassword, newPassword string, ri RequestInfo) error

ChangePassword changes a user's password after verifying the old password. The password policy — length, then the configured PasswordChecker — applies only to the new password; the old one was already validated when it was set, and re-judging it here would refuse the change to exactly the user who most needs to make it.

func (*Sulis) ClearSessionCookie

func (s *Sulis) ClearSessionCookie() *http.Cookie

ClearSessionCookie returns an *http.Cookie that, once set on the response with http.SetCookie(w, cookie), instructs the browser to delete the session cookie immediately: the same Name/Path/HttpOnly/Secure/ SameSite as SessionCookie, an empty Value, and both MaxAge=-1 and an Expires in the past — belt and suspenders, since not every HTTP client or intermediary proxy honors MaxAge.

func (*Sulis) CompleteTwoFactor

func (s *Sulis) CompleteTwoFactor(ctx context.Context, userID, rawToken string, ri RequestInfo) (*LoginResult, error)

CompleteTwoFactor consumes a two-factor pending-login token issued by CreateTwoFactorToken and, once the app has independently verified the user's second factor, issues a new session. The token is single-use and purpose-scoped: it cannot be replayed, and it is rejected by any flow other than CompleteTwoFactor. Also returns ErrEmailNotVerified — as defense in depth, since the token is consumed either way — if the account's email is unverified and RequireVerifiedEmail is enabled (default); this checks the user's current state, not its state when the token was minted.

userID must be the ID the app obtained from its own VerifyPassword call and carried through its own server-side state (e.g. keyed by the pending token) — never a value supplied by the client on the second-factor request. The token is consumed first and then checked against userID, rejecting with ErrTokenInvalid on a mismatch; either way the token is burned, so a mismatched userID cannot be retried against the same token.

func (*Sulis) ConfirmEmailChange

func (s *Sulis) ConfirmEmailChange(ctx context.Context, rawToken string) (*User, error)

ConfirmEmailChange consumes a token issued by ChangeEmail. If the token still matches the account's currently staged address, it makes that address live: Email is swapped in from PendingEmail, PendingEmail is cleared, and EmailVerifiedAt is re-stamped with a fresh timestamp — the old stamp proved control of the old address, not this one. The swap also revokes every session on the account and purges its outstanding password-reset, two-factor, and magic-link tokens, since all three were minted against (or reachable through) the identity that just changed. The magic-link purge in particular is what makes this a recovery rather than a half-measure: a magic link requested while the attacker still had the mailbox is redeemed by user ID, with no check on the address it was sent to, so the swap alone would not stop it.

Returns ErrTokenInvalid if the token is unknown, expired, already used, of the wrong purpose, or bound to an address that is no longer the account's PendingEmail — the last case means a later ChangeEmail call has since superseded it, and the token for the abandoned address must not still be able to claim the account. Returns ErrUserAlreadyExists if another account has claimed the staged address since it was staged.

sulis does not send mail. Callers MUST notify the OLD address once this succeeds — that is how a victim whose address was just changed out from under them learns of it, even though the takeover has already completed.

func (*Sulis) CreateEmailVerificationToken

func (s *Sulis) CreateEmailVerificationToken(ctx context.Context, userID string) (string, error)

CreateEmailVerificationToken generates a short-lived, single-use token proving control of the given user's registered email address. The token is bound to the user's current (normalized) email at issuance time, so it is invalidated by VerifyEmail if the address changes before redemption. The raw token is returned so the consumer can deliver it (e.g. via email).

func (*Sulis) CreateMagicLinkToken

func (s *Sulis) CreateMagicLinkToken(ctx context.Context, email string, ri RequestInfo) (token, bindingNonce string, err error)

CreateMagicLinkToken generates a magic link token for the given email and, when magic-link binding is enabled (WithMagicLinkBinding, on by default), a companion binding nonce. If no user exists for the email, the token is issued without creating a user row — the user is created at redemption time (see RedeemMagicLink) so that requesting magic links for arbitrary addresses cannot be used to flood the user store before anything is ever delivered. The raw token is returned so the consumer can deliver it (e.g. via email).

The raw bindingNonce, when non-empty, must be set by the caller as a short-lived, HttpOnly cookie on the response to THIS request — never embedded in the emailed link itself — and read back from that cookie when the link is later clicked, to pass to RedeemMagicLink alongside the token recovered from the link. See WithMagicLinkBinding for the full wiring, the reasoning, and the empty-string case when binding is disabled.

func (*Sulis) CreatePasswordResetToken

func (s *Sulis) CreatePasswordResetToken(ctx context.Context, email string, ri RequestInfo) (string, error)

CreatePasswordResetToken generates a password reset token for the given email and returns the raw token so the consumer can deliver it (e.g. via email).

If no account exists for email, it returns ("", nil) rather than ErrUserNotFound: this endpoint must not let a caller learn whether an address is registered. The unknown-user path still generates and hashes a token of the same size the known-user path would create — burning the same randomness and hashing work — before discarding it, so the two paths can't be told apart by the work they perform either. What can't be equalized is the store round trip: the known-user path writes a token row and the unknown-user path never does, since there is no user to attach one to. That residual asymmetry is the same kind VerifyPassword documents for its dummy-hash equalization above — perfect timing equality across a storage boundary isn't a claim this library can make.

Admin tooling that has already authenticated an operator and genuinely needs to know whether the address is registered should call CreatePasswordResetTokenStrict instead; it must never back a public-facing endpoint, or it reopens the user-enumeration oracle this method closes.

func (*Sulis) CreatePasswordResetTokenStrict

func (s *Sulis) CreatePasswordResetTokenStrict(ctx context.Context, email string, ri RequestInfo) (string, error)

CreatePasswordResetTokenStrict behaves exactly like CreatePasswordResetToken except that it returns ErrUserNotFound verbatim for an unknown address instead of silently returning ("", nil). It exists for admin tooling that needs the truth about whether an address is registered; wiring it to a public-facing endpoint reintroduces the enumeration oracle CreatePasswordResetToken exists to close.

func (*Sulis) CreateTwoFactorToken

func (s *Sulis) CreateTwoFactorToken(ctx context.Context, userID string) (string, error)

CreateTwoFactorToken generates a short-lived, single-use pending-login token for a user who has passed the first authentication factor. Returns ErrEmailNotVerified if the account's email is unverified and RequireVerifiedEmail is enabled (default), failing before the app ever prompts for a second factor.

Intended app flow: VerifyPassword -> (app checks its own "user has 2FA" flag) -> CreateTwoFactorToken -> (app verifies the second factor: TOTP, recovery code, or passkey) -> CompleteTwoFactor. No session exists until CompleteTwoFactor succeeds.

func (*Sulis) DisableUser

func (s *Sulis) DisableUser(ctx context.Context, userID, reason string) error

DisableUser marks userID as disabled, effective immediately: it stamps User.DisabledAt and records reason (caller-supplied context — sulis never inspects it, see User.DisabledReason), then revokes every existing session for the account.

The write that marks the account disabled happens BEFORE the session revocation, not after, and revocation is best treated as an optimization for immediate cutoff rather than the mechanism disabling actually depends on: even if DeleteUserSessions itself failed, every one of those sessions would still die on its very next use, because ValidateSession checks DisabledAt on every call. Without that check, disabling would leave live sessions working for the remainder of their natural lifetime — this is why ValidateSession's own check is the one piece of this feature that matters most.

Returns ErrUserNotFound if no such user exists.

func (*Sulis) EnableUser

func (s *Sulis) EnableUser(ctx context.Context, userID string) error

EnableUser reverses a previous DisableUser call: DisabledAt and DisabledReason are reset to their zero values, and authentication works again on the next attempt. It does not touch LockedUntil or FailedLoginAttempts — those belong to the separate automatic-lockout mechanism (see WithFailureLockout), and an operator re-enabling a manually disabled account is not the same event as a lockout window expiring; EnableUser should not silently forgive an in-progress lockout the operator may not even know about. It also does not restore any session DisableUser revoked — the account can simply start new ones.

Returns ErrUserNotFound if no such user exists.

func (*Sulis) IssueSession

func (s *Sulis) IssueSession(ctx context.Context, auth Authentication) (*Session, string, error)

IssueSession creates a new session for the user identified by auth, which must come from completing a factor sulis itself verified. The zero value Authentication{} — and, since no exported constructor takes a bare user ID, any other Authentication not obtained from such a flow — is rejected with ErrNotAuthenticated before any store is touched.

Beyond that check, this behaves exactly like IssueSessionUnchecked: ErrUserNotFound if the proof's user no longer exists, and ErrEmailNotVerified if the account's email is unverified and RequireVerifiedEmail is enabled (default).

Applications authenticating by a factor sulis does not know how to verify itself — most notably a finished passkey ceremony, verified entirely by the passkey subpackage and the calling application — have no way to obtain an Authentication and must call IssueSessionUnchecked instead.

func (*Sulis) IssueSessionUnchecked

func (s *Sulis) IssueSessionUnchecked(ctx context.Context, userID string, method AuthMethod) (*Session, string, error)

IssueSessionUnchecked creates a new session for userID without requiring an Authentication proof. It is IssueSession's old, unguarded behavior kept under a name that says so in code review: legitimate for a factor sulis does not know about — most commonly a finished passkey ceremony, which has no way to produce an Authentication — but calling it means the CALLER, not this package, is vouching that userID has completed every factor the application requires. sulis performs no credential check of its own here, only the same ErrUserNotFound / ErrEmailNotVerified gating IssueSession applies. method records which credential the caller is vouching for; sulis does not yet act on it beyond that, but capturing it keeps this method's contract symmetric with IssueSession's.

func (*Sulis) ListUserSessions

func (s *Sulis) ListUserSessions(ctx context.Context, userID string) ([]Session, error)

ListUserSessions returns every session belonging to userID, most useful for a "where you're signed in" device-management screen: each entry carries CreatedAt, LastSeenAt, AuthenticatedAt, Method, IP, and UserAgent, enough for an application to render something like "Chrome, last active 2 hours ago" and let the user revoke anything they don't recognize via RevokeSession.

TokenHash is stripped to "" on every returned Session — this is the security property the task that added this method exists for. The store method behind this (SessionStore.ListUserSessions) returns TokenHash exactly as stored, the same as GetSessionByTokenHash; blanking it before it ever reaches a caller happens here, once, rather than depending on every current and future listing path remembering to do it themselves.

func (*Sulis) Login

func (s *Sulis) Login(ctx context.Context, email, password string, ri RequestInfo) (*LoginResult, error)

Login authenticates a user with email and password.

A correct password is only the FIRST factor. If the configured SecondFactorChecker reports that the user has one enrolled, the returned LoginResult has NeedsSecondFactor set and carries a PendingToken instead of a session — no session exists until CompleteTwoFactor succeeds. Callers must branch on NeedsSecondFactor rather than assuming a non-nil result means the user is logged in.

Returns ErrInvalidCredentials if the email or password is wrong, and ErrEmailNotVerified if the account is unverified and RequireVerifiedEmail is enabled (the default).

func (*Sulis) ReAuthenticate

func (s *Sulis) ReAuthenticate(ctx context.Context, session *Session, password string, ri RequestInfo) error

ReAuthenticate verifies password for the user who owns session and, on success, stamps session's AuthenticatedAt with the current time — both on the stored session and on the *Session the caller passed in, so neither a reload nor a fresh ValidateSession call is needed to observe the refresh. It mints no new session and does not rotate the session's token: the session's ID and TokenHash are exactly what they were before the call. This is the write side of the step-up gate RequireRecentAuth checks.

Like VerifyPassword, it is rate-limited on both the account dimension (key "password:"+email, the same budget Login/VerifyPassword/ ChangePassword share, since a stolen session token attempting to brute-force the password here is exactly the risk those guard) and the IP dimension, and it equalizes response timing for a passwordless account by running the same Argon2 work against an internal dummy hash rather than returning early. Returns ErrInvalidCredentials for a passwordless account or a wrong password — in neither case is AuthenticatedAt touched.

A successful verification here can also upgrade the stored hash, exactly like VerifyPassword's success path: if the hash is weaker than the currently configured Argon2Params, or predates NFKC normalization and matched only through verifyPassword's pre-normalization fallback, it is re-hashed with the plaintext just verified and written back, best-effort (see password.go's needsRehash and sulis.go's rehashPassword). This is deliberate, not an oversight left over from T504: ReAuthenticate is a real password comparison against a real stored hash, so it upgrades the same as any other one — see the T504 (fix round 1) Decisions row.

Also returns ErrAccountDisabled/ErrAccountLocked via accountStatus, checked right after loading the user and before spending an Argon2 verification on a call that cannot succeed either way. Unlike VerifyPassword's oracle-ordering concern (an unauthenticated caller must not learn account status without proving a password first), ReAuthenticate has no equivalent exposure to guard against: the caller already holds a valid *Session for this exact account — proof enough that the account exists — so checking status before the password costs nothing extra in exchange for not refreshing AuthenticatedAt on a disabled or locked account's already-held session. This closes the gap the T501 Decisions row deferred: see PROGRESS.md.

Concurrency caveat: on success, ReAuthenticate writes session.AuthenticatedAt directly on the *Session pointer the caller passed in, with no locking of its own around that write. That is exactly what lets the caller observe the refresh without a reload (see above), but it also means an application that shares one *Session across goroutines — caching it per user, say, rather than fetching a fresh one from ValidateSession per request — is responsible for synchronizing its own reads and writes of that pointer. ReAuthenticate does not, and cannot, do that synchronization on the application's behalf.

func (s *Sulis) RedeemMagicLink(ctx context.Context, rawToken, bindingNonce string, ri RequestInfo) (*LoginResult, error)

RedeemMagicLink validates a magic link token and, when the token carries a stored NonceHash (magic-link binding was enabled at issuance — see WithMagicLinkBinding, on by default), the bindingNonce that must accompany it. If the token was issued before the user existed, the user is created now, as a passwordless account.

bindingNonce must equal the raw nonce CreateMagicLinkToken returned alongside this same rawToken (compared via its SHA-256 hash, in constant time via crypto/subtle.ConstantTimeCompare) — typically recovered from the short-lived HttpOnly cookie the application set at issuance time; see WithMagicLinkBinding for the full wiring and reasoning. A missing or wrong bindingNonce is rejected with ErrTokenInvalid, exactly like a missing or wrong token, so neither leaks which half was the problem. When the token carries no NonceHash — binding was disabled when it was issued — any bindingNonce is accepted, including "".

The binding check runs AFTER the token is consumed (see consumeToken), primarily because consumeToken's atomicity contract — one indivisible find-and-mark-used operation — has no room for a nonce check in the middle of it without either breaking that atomicity (a check-first design would need to read the row, check the nonce, and mark it used as three separate steps, reopening exactly the TOCTOU consumeToken's single operation exists to close) or widening TokenStore.ConsumeToken to accept and verify a nonce hash itself, a larger interface change this task does not make. A secondary effect of the ordering, the same fail-safe direction expiry is already checked in: a wrong nonce still burns the token, so an attacker who obtains a token but not its nonce gets exactly one attempt rather than unlimited retries against a token that stays live — though with a 128-bit nonce, guessing was never the realistic threat this closes; the atomicity constraint is.

A magic link is a FULL first factor — proving control of the mailbox is equivalent to knowing the password — so it is gated by two-factor authentication exactly like Login. If the account has a second factor enrolled, the returned LoginResult carries a PendingToken rather than a session. Without this, anyone able to read the mailbox would bypass 2FA entirely, which is precisely the attacker a second factor exists to stop.

func (*Sulis) RefreshSession

func (s *Sulis) RefreshSession(ctx context.Context, session *Session) (*Session, string, error)

RefreshSession rotates session's token: it retires session's old row first and, only if that succeeds, mints a new session row with a new ID and a new raw token, extending ExpiresAt from now while carrying UserID, Method, AuthenticatedAt, CreatedAt, IP, UserAgent, and Metadata forward unchanged.

AuthenticatedAt is preserved deliberately: a refresh is a liveness/ rotation operation, not a fresh authentication proof, and must not reset the step-up clock RequireRecentAuth reads.

The returned *Session has a different ID and TokenHash than session — this is a new store row, not an in-place update to the one passed in. Deliberate: SessionStore has no primitive to rewrite a session's token and expiry in place (TouchSession and UpdateAuthenticatedAt each update a narrow, different pair of columns), so building a fresh row from the existing CreateSession/DeleteSession pair avoids adding a third narrow-purpose update method to the store contract for the sake of one caller. Rotating the ID is also a small defense-in-depth win: a previously-leaked session ID stops referring to anything live the moment this call succeeds.

The OLD row is deleted FIRST, and RefreshSession only proceeds to mint a new one if that delete actually succeeds — this is a fail-closed liveness check, not an optimization. Without it, a caller holding a stale *Session obtained before a revocation (RevokeSession, RevokeAllSessions, or a device evicted through the ListUserSessions screen this package builds) could call RefreshSession and mint a brand-new working session anyway, un-evicting themselves: CreateSession never consults whether the old row still exists, so a create-then-delete order with the delete's result discarded lets exactly that happen. DeleteSession returning ErrSessionNotFound (the old row is already gone) is therefore propagated verbatim, before any new row is created. This is the same "burn first, validate second" direction consumeToken and passkey's ConsumeChallenge already take ("failures burn the token") and the reason DeleteSession's own ownership-scoped delete-with-error-on-zero-rows exists in the first place: the cost is a crash window between the delete and the create logging the caller out, which is the safe direction to fail in, not an account left refreshable after it should not be.

For the same reason, this reloads the user and checks accountStatus before minting, closing the one remaining way a stale *Session could still refresh into a live one: DisableUser's own session revocation could legitimately fail (store error) while its DisabledAt stamp still lands, leaving the old row intact for DeleteSession to happily remove above — without this check, that would be enough to mint a fresh session for a disabled account, since a newly-minted row never passes back through ValidateSession's own DisabledAt gate. Both checks run after the delete succeeds, so a disabled-account refresh still burns the caller's old session on its way to ErrAccountDisabled, consistent with the fail-closed direction above.

The reloaded user is then held to RequireVerifiedEmail (default true) as well, returning ErrEmailNotVerified — a refresh mints a session, and every other minting path applies that gate. Register's signup session is the one deliberate exemption, so that a new user can hold a session long enough to click the verification link; without this check that exemption never expired, because the signup session could be rotated indefinitely and an account that never verified would keep a live session forever. The same delete-first ordering applies here too: a refresh refused for an unverified account still costs the caller their old session, exactly as the disabled-account case does. That is the intended trade — the caller verifies their address and signs in again, and the alternative (mint first, gate after) is the failure mode this whole ordering exists to prevent. Pass WithRequireVerifiedEmail(false) to restore unconditional rotation.

RefreshSession takes no RequestInfo — Appendix A gives it none — so IP and UserAgent are carried forward from the caller's (possibly stale) in-memory session rather than re-derived from the current request. A long-lived session refreshed repeatedly from a new IP can therefore show a stale IP/UserAgent in a "where you're signed in" listing even while LastSeenAt looks current; see the PROGRESS.md Decisions row.

func (*Sulis) Register

func (s *Sulis) Register(ctx context.Context, email, password string, ri RequestInfo) (*User, *Session, string, error)

Register creates a new user with the given email and password, and returns a new session. Returns ErrUserAlreadyExists if the email is already taken.

func (*Sulis) RequireCSRFToken

func (s *Sulis) RequireCSRFToken(next http.Handler) http.Handler

RequireCSRFToken is the package-level RequireCSRFToken bound to this Sulis, so a rejection reaches the configured EventSink as EventCSRFRejected. The check itself is identical — same VerifyCSRFToken, same 403, same Cache-Control — and either form may be used; this one is simply the one that can report.

A method and a package-level function of the same name is deliberate rather than a rename: RequireCSRFToken is already-shipped public API, and emitting requires state (the sink) that a free function has no way to reach. See the T509 Decisions row in PROGRESS.md.

func (*Sulis) RequireRecentAuth

func (s *Sulis) RequireRecentAuth(ctx context.Context, session *Session, maxAge time.Duration) error

RequireRecentAuth returns ErrReauthRequired if session's AuthenticatedAt is older than maxAge, and nil otherwise. It does not touch any store: it is a pure check against the *Session the caller already holds (typically the one ValidateSession just returned), so gating an endpoint with it costs no extra round trip.

A session issued before this field existed, or otherwise never stamped, reads back with the zero time for AuthenticatedAt. time.Since of the zero time is on the order of two thousand years, which is older than any realistic maxAge, so such a session always fails this check — fail closed, not "treat an absent stamp as fresh."

Gate security-relevant account changes behind this rather than a bare session: enrolling or replacing a TOTP factor (totp.Service.Enroll, ReplaceEnrollment), adding or removing a passkey, disabling two-factor authentication, changing email (ChangeEmail), and regenerating recovery codes should all require proving the credential again, not merely holding a cookie from hours ago. See the README's "Step-up authentication" section for the full list and example wiring.

func (*Sulis) RequireSameOrigin

func (s *Sulis) RequireSameOrigin(allowed []string) func(http.Handler) http.Handler

RequireSameOrigin is the package-level RequireSameOrigin bound to this Sulis, so a rejection reaches the configured EventSink as EventSameOriginRejected — carrying ReasonCrossSite or ReasonOriginNotAllowed, which of the two checks refused. The policy is identical in every other respect; see the package-level function's doc comment for it, and the T509 Decisions row in PROGRESS.md for why this is a same-named method rather than a rename.

func (*Sulis) ResetPassword

func (s *Sulis) ResetPassword(ctx context.Context, rawToken, newPassword string) error

ResetPassword resets a user's password using a raw reset token. The password policy is checked before the token is consumed, so a policy failure does not burn the token.

func (*Sulis) RevokeAllSessions

func (s *Sulis) RevokeAllSessions(ctx context.Context, userID string) error

RevokeAllSessions deletes all sessions for a user.

func (*Sulis) RevokeSession

func (s *Sulis) RevokeSession(ctx context.Context, userID, sessionID string) error

RevokeSession deletes a single session belonging to userID. It returns ErrSessionNotFound if sessionID does not exist or belongs to a different user, so a caller can only ever revoke their own sessions — guessing or leaking another user's session ID cannot be used to end their session.

func (*Sulis) SessionCookie

func (s *Sulis) SessionCookie(rawToken string, expires time.Time) *http.Cookie

SessionCookie returns an *http.Cookie carrying rawToken as its value, ready to be set on the response with http.SetCookie(w, cookie). Every attribute a secure session cookie needs is fixed here, not left to the caller:

  • HttpOnly: never readable by JavaScript, closing off the most common session-theft vector (script injection reading document.cookie).
  • Secure: never sent over plain HTTP.
  • SameSite=Lax: sent on top-level, safe-method navigation and same-site requests; withheld from cross-site subrequests and cross-site state-changing navigation, which is most of CSRF exposure with no extra work. It is NOT a substitute for RequireSameOrigin/ RequireCSRFToken — see the README's "Cookie sessions and CSRF" section for what SameSite alone does and doesn't cover.
  • Path=/: the cookie is valid for the whole origin, matching the __Host- prefix requirement below.

The cookie's Name is CookieName (default: "__Host-session", see WithCookieName), and the __Host- prefix's other two requirements — Secure and no Domain attribute — are exactly what this method always sets/omits, regardless of name, so the guarantee never silently stops applying. See defaultCookieName's doc comment for why this is enforced by construction rather than by validating the combination at runtime.

func (*Sulis) SetInitialPassword

func (s *Sulis) SetInitialPassword(ctx context.Context, userID, newPassword string) error

SetInitialPassword sets the first password for a passwordless user.

func (*Sulis) ValidateSession

func (s *Sulis) ValidateSession(ctx context.Context, token string) (*Session, *User, error)

ValidateSession validates a session token and returns the session and user. Returns ErrSessionNotFound or ErrSessionExpired on failure.

A session past its idle deadline (IdleExpiresAt, set only when WithIdleTimeout is configured) is rejected the same way as one past its absolute ExpiresAt — checked first, since idle expiry exists to end a session well before its absolute lifetime in the common case, and either way the outcome (ErrSessionExpired, the row deleted) is identical.

On success, LastSeenAt/IdleExpiresAt are refreshed via TouchSession, but only when the session's current LastSeenAt is already older than sessionTouchInterval — see that constant's doc comment (session.go) for why this is throttled rather than written on every call. The touch is best effort: a failed write does not fail validation, since the session itself is still valid regardless of whether its liveness bookkeeping happens to update this time.

func (*Sulis) VerifyEmail

func (s *Sulis) VerifyEmail(ctx context.Context, rawToken string) (*User, error)

VerifyEmail consumes an email-verification token issued by CreateEmailVerificationToken and stamps the user's EmailVerifiedAt. The token is single-use and purpose-scoped: it cannot be replayed, and it is rejected by any flow other than VerifyEmail. It is also rejected with ErrTokenInvalid if the user's email has changed since the token was issued, so a verification token can never prove control of an address the user no longer holds.

func (*Sulis) VerifyPassword

func (s *Sulis) VerifyPassword(ctx context.Context, email, password string, ri RequestInfo) (*User, error)

VerifyPassword checks an email and password against the stored credentials without creating a session. Returns ErrInvalidCredentials if the email or password is wrong. Like Login, it equalizes response timing for unknown-user and passwordless-user cases by running the same Argon2 work against a dummy hash.

One further timing note, narrow enough to rarely matter: a successful verification against a hash written before NFKC normalization existed (see the README's "Upgrading" section) costs a second Argon2 comparison — one for an account that has already migrated, two for one that hasn't logged in since. The gap closes for good after that account's next successful login, and it only exists at all for a password containing characters an already-normalized (e.g. plain ASCII) password never has. See verifyPassword's doc comment (password.go) for the full accounting, including why this is not a guessing oracle.

type Token

type Token struct {
	ID        string
	UserID    string
	TokenHash string // SHA-256 hash of the raw token; raw token is never stored
	Purpose   TokenPurpose
	ExpiresAt time.Time
	CreatedAt time.Time
	Used      bool
	// Email records the address a token proves control of. It is set for
	// magic-link tokens issued before the user account exists (UserID is
	// empty in that case until the token is redeemed) and for
	// email-verification tokens (bound to the user's email at issuance, so a
	// later address change invalidates an outstanding token). It is empty
	// for password-reset and two-factor tokens.
	Email string
	// NonceHash is the SHA-256 hash of a magic-link binding nonce (see
	// WithMagicLinkBinding) — the raw nonce is never stored, matching
	// TokenHash's own treatment of the raw token. It is set only for a
	// magic-link token issued while binding was enabled (the default);
	// empty for every other token purpose, and empty for a magic-link
	// token issued while binding was disabled — RedeemMagicLink accepts
	// any bindingNonce, including "", whenever NonceHash is empty.
	NonceHash string
}

Token represents a single-use, time-limited token for password resets or magic links.

type TokenPurpose

type TokenPurpose string

TokenPurpose identifies the intended use of a token.

const (
	TokenPurposePasswordReset     TokenPurpose = "password_reset"
	TokenPurposeMagicLink         TokenPurpose = "magic_link"
	TokenPurposeTwoFactor         TokenPurpose = "two_factor"
	TokenPurposeEmailVerification TokenPurpose = "email_verification" // #nosec G101 -- a purpose label, not a credential
	TokenPurposeEmailChange       TokenPurpose = "email_change"       // #nosec G101 -- a purpose label, not a credential
)

type TokenSource

type TokenSource int

TokenSource controls which channel(s) Authenticate accepts a session token from. See WithTokenSource.

const (
	// TokenSourceBoth accepts either an Authorization: Bearer header or the
	// configured session cookie — today's behavior, and the default.
	//
	// This stays the default even though this package now ships cookie
	// support (SessionCookie) and CSRF defenses (RequireSameOrigin,
	// RequireCSRFToken) in the same task that introduced this type: a
	// Bearer header is never attached to a request automatically by a
	// browser, so accepting one alongside a cookie does not create or
	// widen a CSRF exposure by itself — that exposure comes entirely from
	// the cookie channel, and is exactly what RequireSameOrigin/
	// RequireCSRFToken exist to close. Narrowing the default to
	// TokenSourceCookieOnly would break every existing Bearer-only
	// consumer for no CSRF benefit, since Bearer was never the risk.
	// See the T507 Decisions row in PROGRESS.md.
	TokenSourceBoth TokenSource = iota
	// TokenSourceCookieOnly rejects an Authorization: Bearer header
	// entirely — Authenticate never even reads it — and honors only the
	// configured session cookie.
	TokenSourceCookieOnly
	// TokenSourceBearerOnly rejects the session cookie entirely —
	// Authenticate never even reads it — and honors only an Authorization:
	// Bearer header. A deployment that sets this, and never calls
	// SessionCookie, needs neither RequireSameOrigin nor the CSRF helpers:
	// without a cookie there is no ambient credential for a forged
	// cross-site request to ride on.
	TokenSourceBearerOnly
)

type TokenStore

type TokenStore interface {
	CreateToken(ctx context.Context, token *Token) error
	// ConsumeToken atomically finds the unused token matching hash AND purpose
	// and marks it used, returning it. Lookup and mark MUST be one atomic
	// operation (e.g. UPDATE ... WHERE hash=? AND purpose=? AND used=false).
	// Returns ErrTokenNotFound if no token matches hash+purpose;
	// ErrTokenAlreadyUsed if it exists but was already consumed.
	ConsumeToken(ctx context.Context, hash string, purpose TokenPurpose) (*Token, error)
	DeleteExpiredTokens(ctx context.Context) error
	// DeleteUserTokens deletes all tokens for the given user and purpose.
	// Deleting zero tokens is not an error.
	DeleteUserTokens(ctx context.Context, userID string, purpose TokenPurpose) error
}

TokenStore defines the persistence operations for tokens.

type User

type User struct {
	ID           string
	Email        string
	PasswordHash string // empty for passwordless-only users
	CreatedAt    time.Time
	UpdatedAt    time.Time
	Metadata     map[string]any
	// EmailVerifiedAt records when the user's email address was confirmed as
	// reachable (e.g. via VerifyEmail or a redeemed magic link). Nil means
	// the address has not been verified.
	EmailVerifiedAt *time.Time
	// PendingEmail holds a staged address awaiting proof of control, set by
	// ChangeEmail. The live Email field never changes except through a
	// successful ConfirmEmailChange, which also clears this back to empty.
	PendingEmail string
	// DisabledAt records when DisableUser took the account out of service.
	// Nil means the account is active. VerifyPassword's post-verification
	// check, completeFirstFactor, issueSessionForUser, and CompleteTwoFactor
	// all reject with ErrAccountDisabled while it is set, and ValidateSession
	// rejects an already-issued session the same way — so disabling an
	// account invalidates every session already issued, not merely future
	// logins. Cleared only by EnableUser.
	DisabledAt *time.Time
	// DisabledReason is caller-supplied context recorded by DisableUser
	// (e.g. "reported for abuse", "closed by support"). sulis never inspects
	// it. EnableUser clears it back to empty alongside DisabledAt.
	DisabledReason string
	// LockedUntil records the end of a temporary authentication lockout.
	// Nil, or a time already in the past, means the account authenticates
	// normally. It is set only by the optional automatic-lockout mechanism
	// (see WithFailureLockout) after repeated wrong passwords; the same
	// post-verification checks that reject ErrAccountDisabled also reject
	// ErrAccountLocked while this is still in the future. It is cleared
	// (along with FailedLoginAttempts) the next time a correct password
	// verifies outside the window, or the account's password is
	// successfully changed or reset (ChangePassword, ResetPassword,
	// SetInitialPassword) — there is no explicit unlock call for either.
	// Unlike DisabledAt, an active lock does not invalidate sessions already
	// issued: ValidateSession does not check it, only new authentication
	// does (see the README's "Account disable and lockout" section for why);
	// and unlike DisabledAt, a password reset/change DOES clear it — proving
	// control of the account well enough to set a new password is at least
	// as strong an identity proof as the login password itself, whereas
	// DisabledAt records an operator's decision that no proof of the
	// password reverses.
	LockedUntil *time.Time
	// FailedLoginAttempts counts consecutive wrong passwords since the last
	// correct one. It only ever advances when WithFailureLockout is
	// configured, and is reset to 0 whenever a correct password verifies
	// outside an active lockout window, or the account's password is
	// successfully changed or reset.
	FailedLoginAttempts int
	// Version guards against lost updates. It is set by the store on read and
	// must be passed back unchanged in UpdateUser, which applies the write
	// only if it still matches the persisted row. Callers outside the store
	// never set it themselves.
	Version uint64
}

User represents an authenticated user.

func UserFromContext

func UserFromContext(ctx context.Context) (*User, bool)

UserFromContext retrieves the authenticated user from the request context.

type UserStore

type UserStore interface {
	// CreateUser persists a new user. Returns ErrUserAlreadyExists if user.Email
	// is already the live address of another user.
	CreateUser(ctx context.Context, user *User) error
	GetUserByID(ctx context.Context, id string) (*User, error)
	GetUserByEmail(ctx context.Context, email string) (*User, error)
	// UpdateUser persists user, but ONLY if the stored row's version still
	// equals user.Version. On success the stored version MUST be incremented;
	// on mismatch the write MUST be discarded and ErrConcurrentUpdate
	// returned. Without this, two flows that each read-modify-write the whole
	// row can clobber each other — and the dangerous direction restores a
	// password hash the user just rotated away from.
	//
	//	UPDATE users SET ..., version = version + 1
	//	 WHERE id = $1 AND version = $2
	//
	// Zero rows affected means another writer won: return ErrConcurrentUpdate.
	//
	// UpdateUser MUST also return ErrUserAlreadyExists if user.Email would
	// collide with a different user's live email — e.g. two accounts racing
	// to confirm a change to the same staged address. This is the real
	// guarantee behind that race, not the in-library pre-check described
	// above.
	UpdateUser(ctx context.Context, user *User) error
	DeleteUser(ctx context.Context, id string) error
}

UserStore defines the persistence operations for users. Consumers implement this interface for their own database.

A store MUST NOT share mutable state with its callers in either direction. Metadata is a map and EmailVerifiedAt, DisabledAt, and LockedUntil are each a pointer, so copying a *User with a plain struct assignment copies a map header and an address, not the map and not the time — leaving the caller holding a live handle on the stored row. That is a way to rewrite a persisted user without going through UpdateUser at all, which defeats the Version precondition below by simply stepping around it. Copy the map (one level is enough; values inside it are the caller's business) and each pointed-to time when storing a user and when returning one. Stores that reconstruct rows from a database read get this for free; in-memory ones do not. storetest.RunUserStore checks it.

Email uniqueness MUST be enforced at the storage layer — e.g. a SQL UNIQUE index on the normalized email column — and CreateUser and UpdateUser MUST return ErrUserAlreadyExists when a write would violate it. This is not optional. Version (below) only guards a lost update on a single row; it says nothing about two different rows racing to claim the same address (e.g. two accounts both confirming a staged change to the same address). Nothing above this interface can make those two writes atomic with respect to each other, since by the time either call reaches UserStore they are independent reads and writes on different rows. A caller may re-check uniqueness with GetUserByEmail before writing (ConfirmEmailChange does), but that is only a best-effort early rejection, not the guarantee: two callers can both pass that check for the same address before either write lands. The store's write path enforcing the constraint is what actually closes the race.

Directories

Path Synopsis
Package memstore is the reference in-memory implementation of every store interface sulis defines.
Package memstore is the reference in-memory implementation of every store interface sulis defines.
Package passkey implements WebAuthn-based passkey registration and authentication.
Package passkey implements WebAuthn-based passkey registration and authentication.
Package passwordcheck screens passwords against known-compromised values.
Package passwordcheck screens passwords against known-compromised values.
Package recovery implements one-time recovery codes as a fallback for two-factor authentication: when a user loses their TOTP device or passkey, a recovery code lets them regain access without a support-driven bypass.
Package recovery implements one-time recovery codes as a fallback for two-factor authentication: when a user loses their TOTP device or passkey, a recovery code lets them regain access without a support-driven bypass.
store
sql module
Package storetest is the conformance suite for the persistence interfaces sulis defines.
Package storetest is the conformance suite for the persistence interfaces sulis defines.
Package totp implements TOTP (RFC 6238) with zero external dependencies.
Package totp implements TOTP (RFC 6238) with zero external dependencies.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL