Quick start#
vukem install:auth # login, registration, email verification, password reset, tests
vukem migrateinstall:auth copies code you own into the project and registers its migrations and routes:
A password reset and turning on 2FA end the user's other sessions on every session driver:
Accounts.RevokeOtherSessionsbumpsusers.session_version, which signs out encrypted cookie sessions too, and deletes server-side records (Sessions).
app/models/user.go User (AuthID, AuthPassword, AuthSessionVersion, EmailVerified)
app/accounts/{accounts,users,passwords}.go guards, registration, verification, password resets
app/accounts/{messages.go,lang/} auth messages in en and pt-BR (accounts.T)
app/controllers/account/*.go login, register, logout, forgot/reset password, verification, dashboard
app/requests/auth.go z schemas (password at least 12 characters, confirmation)
app/mail/auth.go VerifyEmail, ResetPassword and AccountExists mailables
database/migrations/* users, remember tokens, access tokens, password resets
resources/views/auth/*.html, resources/views/mail/*.html
routes/auth.go appended to routes.All()
tests/auth_test.go the whole flow
tests/auth_messages_test.go every message translated
tests/sessions_test.go a password reset ends the user's other sessions| Flow | Behaviour |
|---|---|
| Register | the same answer for new and existing emails (redirect to /register/check-email, no validation error, no login): a new email gets the account and a verification mail; an existing one gets a "someone tried to sign up" notice (AccountExists, at most one per hour per address through the cache, keyed by a hash of the address) and nothing is created or changed. Both paths hash the password with argon2id and queue one mail (SendLater), so the timing does not reveal accounts either. A cache error on that path skips the notice and is logged, never returned, so an outage does not reveal accounts either. Sign-up is limited to 10 per 10 minutes per IP and 5 per hour per address (keyed by a SHA-256 of the address, through app.RateLimiters, so RATE_LIMIT_STORE=cache shares it); the limit counts requests, not accounts, so it answers 429 alike for new and existing addresses. The per-address limit can keep a specific address from signing up for an hour (anyone can spend its budget), without revealing whether it has an account |
| Login | remember-me, intended URL, 5 attempts per minute per email + IP, the same error for unknown users. Signed-in pages (dashboard, 2FA settings, billing, admin panel) also need a verified email (auth.Verified): an unverified login lands on the verification notice, so an account someone pre-registered with another person's address is useless until that person verifies it (and a password reset then ends the squatter's sessions). POST /api/v1/tokens answers 403 to unverified users, and install:admin admins need a verified ADMIN_EMAILS address |
| Email verification | signed link (HMAC, 1 hour) that works signed out (then leads to /login), goes to the dashboard for its own account and answers 403 to another signed-in user; resend (signed in) limited to 3 per minute. The link carries a SHA-256 of the address (email_hash) and is void once the email changes. Verifying ends every other session (session version bump), remember-me token and API token of the account, so whatever someone opened after signing up first with another person's address dies; a verifier signed in as the account keeps their session (RefreshSession). Known limit: if the owner clicks a verification link for an account they did not create, the account becomes verified with the squatter's password; the mail says to ignore it, and the owner's own sign-up attempt gets the "sign in or reset your password" notice, whose reset ends the squatter's access |
| Forgot / reset password | the same answer for unknown emails; random token stored as SHA-256, 1 hour, single use; reset ends every session (with SESSION_DRIVER=database), remembered session and API token |
| Logout | session invalidated, remember-me token deleted |
Since sign-up never says "this email is taken", a user who forgot they have an account learns it from the notice mail, which links to the login and password reset pages. The trade-off: a sign-up form that cannot tell users their email is taken, and a mail queue that must work (QUEUE_CONNECTION=database in production). Apps that ran install:auth before v0.26.0 keep the old flow (409 for a taken email, login before verification) until they port Register, Verify, routes/auth.go and the new AccountExists mail, or re-run install:auth --force (and install:admin / install:billing / install:api for their verified-email checks). The examples/ apps pin an older vukem and keep the old flow.
Flash messages, controller errors and mail subjects go through accounts.T(ctx, key), with English and Brazilian Portuguese catalogs in app/accounts/lang/<locale>/*.json (keys auth.*; --2fa and --social add two_factor.json and social.json). T uses the request locale that i18n.Middleware resolves when the app uses vukem.WithTranslations, and English otherwise. A key in the app's own catalog for that locale or its language (resources/lang/pt-BR.json, then pt.json) wins, so the app can reword a message or add a locale without editing the scaffold. Field messages of the z schemas in app/requests and the views stay in English. Errors from the framework itself (failed login, unverified email, missing token ability, social sign-in, orm.Unique) carry their own keys and are translated by the built-in catalogs (Localization); the login error stays the same for unknown users and wrong passwords in every locale. tests/auth_messages_test.go checks that every key exists in every locale. Mailables built without a Subject keep the English one.
The reset link mail is queued (mailer.SendLater), so the forgot-password answer does not wait for the mail server and its timing does not tell known emails from unknown ones. That needs a real queue: set QUEUE_CONNECTION=database in production (memory also answers fast, but a restart loses queued reset mails, so users may need to ask again); with the default sync the mail is still sent during the request.
Concepts#
type Authenticatable interface {
AuthID() string // primary key as a string
AuthPassword() string // stored hash
}
type UserProvider[U Authenticatable] interface {
ByID(ctx context.Context, id string) (U, error)
ByCredentials(ctx context.Context, creds map[string]string) (U, error) // never checks the password
}auth.NewORMProvider[models.User](db) looks users up by primary key and by every credential except password, and stores rehashed passwords.
Session guard#
users := auth.NewORMProvider[models.User](db)
webGuard := auth.NewSessionGuard("web", users, hash.Default(), auth.NewRememberStore(db))
r.Post("/login", func(c *web.Ctx) error {
in, err := web.Parse(c, requests.LoginSchema)
if err != nil {
return err
}
creds := map[string]string{"email": in.Email, "password": in.Password}
if _, err := webGuard.Attempt(c, creds, auth.Remember(in.Remember)); err != nil {
return err // 422 on "email": "These credentials do not match our records."
}
return c.Redirect("/dashboard")
})
r.Get("/dashboard", dashboard).Use(auth.Required(webGuard))
user, err := auth.User[models.User](c, "web") // in handlers behind RequiredAttemptchecks the password in constant time; unknown users still cost a hash verification and get the same 422 error. Failures emit theauth.login_failedsecurity event.Loginregenerates the session id (fixation) and stores the user id, also underweb.SessionUserKeysosession.Manager.RevokeOtherscan end the user's other sessions (Sessions);Logoutinvalidates the session and deletes the remember-me token.- Users implementing
auth.SessionVersionerhave their session version recorded on login; once the user's version changes, older sessions are signed out on every driver.RefreshSession(c)keeps the current session across a bump. Remember-me tokens are bound to the version too: a bump also ends remember-me logins, andRefreshSessionre-issues the current device's token. - Remember-me cookies hold
<id>.<secret>; onlySHA-256(secret)is stored, and tokens rotate on every use. - Stale hashes (bcrypt, weaker argon2 parameters) are upgraded on login.
Validate(c, creds)checks credentials without logging in (token issuance).
Token guard#
tokens := auth.NewTokens(db)
tokens.Now = app.Now // the app clock (vukem.WithClock) drives expiry and last use
apiGuard := auth.NewTokenGuard("api", users, tokens)
plain, tok, err := tokens.Create(ctx, user.AuthID(), "cli", []string{"posts:read"}, 30*24*time.Hour)
list, err := tokens.List(ctx, user.AuthID()) // for a "My API keys" page; revoked ones have RevokedAt set; never the secrets
revoked, err := tokens.RevokeFor(ctx, user.AuthID(), id) // id from a form: only revokes the user's own live token
err = tokens.Revoke(ctx, tok.ID) // no owner check: only for the token of this request
err = tokens.RevokeAll(ctx, user.AuthID()) // e.g. after a password change
n, err := tokens.Prune(ctx, app.Now().AddDate(0, 0, -90)) // delete rows revoked or expired 90+ days ago (never live ones)
if auth.TokenCan(c, "api", "posts:read") { /* ... */ } // "*" grants everythingA ttl of 0 in tokens.Create means the token never expires (valid until revoked); the install:api scaffold issues 30-day tokens. Opaque tokens <prefix><id>.<secret> from crypto/rand; only the SHA-256 of the secret is stored, compared in constant time; last_used_at is updated at most once a minute. Revoking sets revoked_at instead of deleting the row, so a "My API keys" page and an audit can still see the token; a revoked token is refused like a wrong one. Tables created before v0.26.0 need the revoked_at column: add auth.TokensRevokedAtMigration("<timestamp>_access_tokens_revoked_at") to your migrations (until then every token is refused with 503 and revoking deletes the row).
Set tokens.Prefix (the install:auth scaffold uses vk_; pick one specific to your app) so new tokens are easy to spot: add a custom secret-scanning pattern such as vk_[A-Z2-7]{26}\.[A-Z2-7]{26} to GitHub or gitleaks. Tokens issued without a prefix, or under an earlier one, keep working.
When a token id comes from the request (a "Revoke" button), always use
RevokeFor: it revokes the token only if it belongs to the signed-in user and is still live, and returnsfalseotherwise, so answer 404. For signed tokens from an identity provider, use the JWT guard; for TOTP, see two-factor authentication.
Middleware#
| Middleware | Effect |
|---|---|
auth.Required(guards...) | first guard that authenticates wins; JSON clients get 401 with WWW-Authenticate: Bearer, browsers a redirect to the route named login (intended URL kept). When every guard is an auth.APIGuard (token and JWT guards), the 401 is always problem+json, never a redirect. A guard whose store fails answers 503 and stops the list, so put the session guard before the token guard on mixed routes: auth.Required(webGuard, apiGuard) |
auth.Optional(guards...) | authenticates when possible, never blocks (pages that show more to members) |
auth.Guest(guards...) | sends logged-in users to the route named home |
auth.Verified(guard) | passes when the user's EmailVerified() is true; otherwise browsers are redirected to the route named verification.notice (created by install:auth) and JSON clients, or apps without that route, get 403 |
auth.Ability(guard, abilities...) | 403 unless the token grants every ability (problem+json for an auth.APIGuard) |
Hashing#
| Driver | Defaults | Notes |
|---|---|---|
| argon2id (default) | m=64 MiB, t=1, p=4, 16-byte salt, 32-byte key | PHC string $argon2id$v=19$m=…,t=…,p=…$salt$hash |
| bcrypt | your cost (12 recommended) | inputs over 72 bytes are rejected, never truncated |
hash.Default(), hash.Argon2id(params) (start from hash.DefaultArgon2Params()), hash.Bcrypt(cost), hash.Fake() for tests. hash.Verify reads both formats.
Testing#
app.Client(t).ActingAs("web", user).Get("/dashboard").AssertOK() // stores what Login stores, session version included
app.Client(t).WithToken(plain).GetJSON("/api/v1/me").AssertOK()