Guide: the accounts app
github.com/angvp/tango/accounts is an optional, installable, first-party reusable app: a ready-made HTML email/password register/login/logout flow, built entirely by composing auth's primitives (see application auth) rather than adding to them.
Install accounts when you want a conventional account system — self-service registration, login, logout — with minimal code, and the default Account/AccountSession shape (below) is close enough to what you need.
Skip accounts and use auth primitives directly when you need a different identity shape (username instead of email, additional required fields at signup, a different session-duration/cookie story per route, a token story other than bearer), or your own login/registration flow entirely. auth has no opinion about any of this and composes the same way accounts itself does — installing accounts is not a prerequisite for using auth.
accounts never shares or converts data with admin's own AdminUser/AdminSession — they are permanently separate identity spaces, the same way auth's own Application user pattern is separate from admin.
Models
type Account struct {
ID int64 `tango:"pk"`
Email string `tango:"unique"`
PasswordHash string
Active bool
CreatedAt time.Time
EmailVerifiedAt time.Time // the zero time means unverified
}
type AccountSession struct {
ID int64 `tango:"pk"`
Token string `tango:"unique"`
UserID int64 `tango:"fk=Account,index"`
ExpiresAt time.Time
}
accounts also registers AccountToken, which stores the hashes of emailed password-reset and verification links (see below).
Account is deliberately bare: identity, credential, activity state, timestamps. tanGO deliberately ships no IsStaff, Role, Group, or Permission-shaped field. An app that needs roles or permissions today queries Account itself (if installed) or its own domain data, from its own View wrapper.
Email is always stored lowercased and trimmed, and every lookup normalizes the same way — [email protected] and [email protected] are always the same Account.
Installing
config := tango.Config{
InstalledApps: []tango.App{
accounts.New(store),
// ... your other apps
},
}
accounts.New(store) with no options is the baseline: signup enabled, a 30-day session in a tango_account_session cookie. Run tango makemigrations/tango migrate as usual once it's installed — Account/AccountSession are ordinary models like any reusable app's.
Options
| Option | Effect | Default |
|---|---|---|
accounts.WithTrustedProxies(networks...) |
For an app behind a reverse proxy: counts failed attempts per forwarded client address, using the rightmost X-Forwarded-For address that is not one of these networks, and only for a connection inside them. Without it the limiters count the connection address and read no header. See running more than one instance. |
|
accounts.WithSignupDisabled() |
Closes self-service registration. /accounts/register/ stays mounted and returns a clear "Registration is currently closed" response (never a bare 404); /accounts/login/ is unaffected. |
Signup enabled |
accounts.WithSessionDuration(d time.Duration) |
How long a created session stays valid. | 30 days — deliberately longer than admin's fixed 24 hours, since public-user and operator expectations differ |
accounts.WithSessionCookieName(name string) |
The session cookie's name. | accounts.DefaultSessionCookieName ("tango_account_session") |
Routes
accounts mounts a fixed prefix, like every other reusable app in tanGO (admin owns /admin/, the greetings example owns /greetings/) — there is no host-configurable mount prefix:
GET/POST /accounts/register/— registration. On success, creates theAccount(Active=trueimmediately), creates a session, and redirects straight to the post-login destination. No separate login step is needed after signing up. With mail enabled, it also emails a verification link; the account is usable before it's verified.GET/POST /accounts/login/— login. Any failure (unknown email, wrong password, or an inactive account) produces the exact same generic error — none of those cases are distinguishable from the response, so a failed attempt never reveals whether a given email is even registered. This is deliberately asymmetric with registration's specific "this email is already registered" error: login is a credential check, where a generic error closes off an oracle for identifying registered emails; registration is a self-service form where a genuine user needs to know why their signup didn't go through, and an email address isn't a secret the way a password is.POST /accounts/logout/— deletes only the current session; any other session belonging to the same account is untouched. NoGETroute.
Both register and login accept a next query parameter/form value, validated against any same-origin path (not restricted to /accounts/, since accounts's login can be reached from protecting any page across your site) — an off-site or malformed next falls back to /.
Both endpoints are CSRF-protected and rate-limited (a small per-IP, in-memory limiter — not distributed, no CAPTCHA, no configurable policy).
Password reset and email verification
Both need outbound mail, so they're off until you pass accounts.WithMail:
sender, err := mail.SMTPSenderFromEnv()
if err != nil {
return err
}
accounts.New(store, accounts.WithMail(accounts.MailConfig{
Sender: sender,
From: "Shop <[email protected]>",
BaseURL: "https://example.com",
}))
BaseURL: the origin links are built on. It must be an absolutehttps://origin, orhttp://on localhost during development. Links never come from the request'sHostheader, which an attacker controls.- Checked at registration: a missing
Sender, a malformedFromor a badBaseURLmakes-checkfail. Logger: an optional*slog.Loggerfor the two events below; nil usesslog.Default().- Without
WithMail: none of the routes below is mounted, andaccountssends nothing.
Routes
GET/POST /accounts/password-reset/asks for a reset link. Every syntactically valid address gets the same answer: an existing, unknown, inactive or recently emailed one. Only an empty or malformed address gets a form error, which says nothing about accounts. The request only queues a job; the background outbox looks the account up and emails a link only to an active account, so neither the response nor its timing reveals whether an account exists.GET/POST /accounts/password-reset/confirm/?token=…sets a new password under registration's rules. The link lasts one hour. A completed reset ends every session and every other link the account has, marks the email verified, and sends the user to log in.GET/POST /accounts/verify/?token=…confirms the email address from the link sent at registration. It lasts 24 hours.POST /accounts/verify/resend/sends a logged-in, unverified account a new verification link.
GET never uses a link: it shows a form or a button, and only POST acts. Email scanners and link previews follow links, and they shouldn't reset a password or verify an address. These pages send Referrer-Policy: no-referrer and Cache-Control: no-store.
An expired, used, replaced or unknown link gets the same "This link is not valid" page. So does a link sent before the account's email changed.
Limits
- Cooldown: each address gets at most one email of each kind every five minutes, and each client IP five reset or resend requests a minute. An email the full outbox drops gives its cooldown back.
- More than one instance: the cooldown and the outbox are per process; see running more than one instance.
- Outbox: emails wait in an in-memory outbox of 100, sent one at a time by a worker that
accountsregisters as a Lifecycle component. - Full outbox: the email is dropped and
tango.accounts.mail_droppedis logged. - Failed send:
tango.accounts.mail_failedis logged, with the address and link redacted; a failure preparing the email is logged by its error class only. - Shutdown: the worker sends what it can before its stop deadline, then the send in progress is cancelled; nothing is sent after shutdown finishes.
- What's lost: a crash loses whatever is queued, and nothing is retried.
- Per process: the cooldown and the outbox are per process, like the login rate limiter.
Requiring a verified email
Verification never stops anyone logging in. To require it for a page, use accounts.RequireVerified, shaped like RequireLogin:
billing := accounts.RequireVerified(store, accounts.DefaultSessionCookieName, "/accounts/login/", Billing)
A logged-out visitor is redirected to log in. A logged-in but unverified account gets a 403 page with a button to send a new link; that button needs WithMail.
Changing an account's email
accounts has no email-change flow. Whoever changes Account.Email, your own code or an operator in admin, must also clear EmailVerifiedAt, because the new address hasn't been proven. They should also delete the account's AccountToken rows. A link sent to the old address already stops working, because each link is tied to the address it was sent to.
See the mail guide.
JSON mode for a single-page app
accounts.WithJSON adds six JSON endpoints for a separate client (a Next.js or other single-page app) that cannot use the cookie-and-HTML flow. It requires WithMail and is checked when the app registers, so -check fails on a bad configuration. Without it nothing changes.
accounts.New(store,
accounts.WithMail(accounts.MailConfig{Sender: sender, From: "[email protected]", BaseURL: "https://api.example.com"}),
accounts.WithJSON(accounts.JSONConfig{
Auth: bearerAuth, // your accounts.JSONAuth: Issue and Authenticate
VerifyURL: "https://app.example.com/verify#token={token}",
ResetURL: "https://app.example.com/reset#token={token}",
OnRegister: createProfile, // optional
ResolveIdentifier: findByUsername, // optional
}),
)
JSON mode is bearer-only. It never reads or sets a cookie and needs no CSRF token, and it creates no AccountSession. accounts does not mint tokens: your JSONAuth.Issue returns an AuthGrant (access token, token type, lifetime) for an account that has just logged in or registered, and Authenticate names the account behind a request's bearer token for the one endpoint that needs it. That keeps accounts independent of any token package; auth/jwt is the usual choice. Bearer tokens are stateless, so there is no logout endpoint, and a password reset cannot revoke an access token that was already issued: keep lifetimes short.
CORS is the host's: tanGO ships none. Allow your client's origin on /accounts/api/ and the Authorization and Content-Type headers in a middleware of your own, as examples/spa-accounts does.
Endpoints
All take and return application/json and live under /accounts/api/.
| Endpoint | Request | Success |
|---|---|---|
POST register/ |
{"email", "password", "profile"?} |
201 {"account", "grant"} |
POST login/ |
{"identifier", "password"} |
200 {"account", "grant"} |
POST password-reset/ |
{"email"} |
202 {"status": "accepted"}, the same for every valid address |
POST password-reset/confirm/ |
{"token", "password"} |
204 |
POST verify/ |
{"token"} |
204 |
POST verify/resend/ |
{}, with Authorization: Bearer … |
202 |
account is {id, email, email_verified}, never the password hash. Responses may gain members in a later release; clients ignore members they do not know. Requests are strict: a body that is not a JSON object, an unknown member, a duplicate member or a member of the wrong type is 400 invalid_body; another content type is 415; a body over 16 KiB is 413. Like every endpoint, verify/resend/ takes a JSON object, here an empty one. Every response, success or error, carries Cache-Control: no-store. Any other method on an endpoint is a JSON 405.
Errors are {"error": "message", "code": "…", "fields"?: {…}}. Match on code: invalid_body, invalid_field (422, with fields keyed by the field), invalid_credentials (401, the same for an unknown identifier, a wrong password and an inactive account), invalid_token (400, the same for an unknown, expired, used or wrong-purpose token), rate_limited (429, with Retry-After), unauthenticated (401, a missing or invalid bearer token), already_registered (409), registration_closed (403), unsupported_media_type, body_too_large, method_not_allowed and internal. The message text is not covered.
JSON mode shares the HTML flow's password rules and email normalization, plus the same per-address cooldowns, per-IP limits, in-memory outbox and enumeration-safe answers. The JSON endpoints additionally require a bare email address ([email protected], not Ada <[email protected]>) of at most 254 characters, and answer 422 invalid_field otherwise. Login always spends one password comparison, even for an unknown identifier, so its timing does not say whether an account exists; this is also true of the HTML login. A completed reset ends every AccountSession and token row of the account.
Emailed links
VerifyURL and ResetURL are templates for the links in the emails that JSON endpoints trigger: an absolute http or https URL, with no credentials, containing {token} exactly once. The HTML flow keeps its own links. Put the token in a fragment (https://app.example.com/reset#token={token}): a fragment is never sent to a server, so it stays out of access logs and Referer headers. A token in a query string can leak through both, and through browser history. The client reads the token from location.hash, clears it, and posts it to password-reset/confirm/ or verify/.
Registration hook and your own data
OnRegister runs inside the transaction that creates the account, after the Account row exists and before it commits, with a *db.Store bound to that transaction (see Store.InTx). It receives a Registration with the account id, the normalized email and the request's profile object untouched. Create your own rows with tx; if the hook fails, the account, your rows and the verification email are all gone.
func createProfile(ctx context.Context, tx *db.Store, reg accounts.Registration) error {
var in struct{ Username string `json:"username"` }
if err := json.Unmarshal(reg.Profile, &in); err != nil {
return accounts.FieldError{Field: "username", Message: "is required"}
}
return tx.Create(ctx, profileMeta, &Profile{AccountID: reg.AccountID, Username: in.Username})
}
Return a FieldError (several can be joined with errors.Join) to answer 422 invalid_field with your public field names, or let tx.Create return a *db.ValueTooLongError from a varchar=n field, or a *db.InvalidJSONError (matching db.ErrInvalidJSON) from a tango:"json" field holding text that is not valid JSON; both are also a 422, keyed by the Go field name, with the message must be valid JSON for the latter. The error never carries the submitted document. Any other error is logged and answered with a generic 500. A panic rolls back and propagates. accounts caps the profile object at 4 KiB of JSON, a guard on request size only; limits on a field's content are the model's own, counted in characters (runes), not bytes.
ResolveIdentifier lets login accept something other than an email, such as a username. An identifier containing @ is always an email and never reaches it; the host guarantees its own identifiers never contain @. It returns the account id; accounts still loads the account, checks Active and compares the password, so an unknown username and a wrong password look the same.
Protecting your own routes
accounts.RequireLogin is a View wrapper for your own routes, mirroring auth.RequireLogin's shape:
protected := accounts.RequireLogin(store, accounts.DefaultSessionCookieName, "/accounts/login/", Dashboard)
Pass whatever cookie name you configured accounts.New with (accounts.DefaultSessionCookieName if you used the default — RequireLogin has no way to see accounts's own configuration, since it's a private detail of the specific accounts.New call that installed the app). An unauthenticated, invalid, expired, or now-inactive-account request redirects to the given login path with next carrying the page the visitor was trying to reach.
Active is checked on every request through an already-valid session, not just at login — deactivating an Account invalidates its standing sessions immediately. This is stricter than auth.SessionUser (which has no notion of Active at all) and matches AdminUser's own precedent.
Current account
account, ok, err := accounts.CurrentAccount(ctx, store, accounts.DefaultSessionCookieName)
if err != nil {
return err
}
if !ok {
// no current account: missing/invalid/expired session, or Active=false
}
accounts.CurrentAccountID returns just the int64 ID if you don't need the full row. Both are thin, Active-aware sugar over auth.CurrentUserID — a plain function call your own Views use, not a route. There is no /accounts/me/ page; a real account-settings/profile page is its own feature surface, left to a later milestone.
Admin integration
There is no dedicated option to register Account with admin — it's fully manual, the same way any reusable app's models are, once you've also installed admin:
registry.Admin().Register(accounts.Account{}, admin.Options{
ListDisplay: []string{"Email", "Active", "CreatedAt"},
Search: []string{"Email"},
ReadOnly: []string{"PasswordHash", "CreatedAt"},
})
Keep PasswordHash read-only (or omit it from ListDisplay/leave it out of any custom field ordering) — it should never be directly editable through the generic admin form. There is no accounts-specific CLI for managing accounts (no tango accounts deactivate <email>); once Account is registered with admin, the generic admin CRUD — including flipping Active — is the whole operational story for managing accounts outside the web flow.
Non-goals
- The default flow is HTML. JSON endpoints exist only with
WithJSON(above), are bearer-only and do not issue tokens themselves. WithoutWithJSON, the only JSON responses are errors a client may need to tell apart from a page: a rejected or missing CSRF token answers403and rate limiting answers429(withRetry-After), each with a JSON{"error": …}object. Closed registration is a403HTML page. See versioning and compatibility for what is promised. - No cookie-based JSON mode, no logout or token-refresh endpoint, no account-update or email-change endpoint, and no CORS handling.
- No custom user model abstraction or interface beyond
auth's own Application user pattern. - No roles, groups, or permissions of any kind on
Account. - No dedicated CLI.
- No template-override hook — write your own login/register views composing
authprimitives directly if you need different HTML. - No
/accounts/me/page. - No host-configurable mount prefix.
- No OAuth/OIDC/social login, no email-change flow, and no customizable email wording: reset and verification emails are fixed English plain text.