Configuration
Setup-first reference for application, browser, authentication, password hashing, permissions, and operations.
Configuration
Build the required application, secret, browser and email settings first. Then choose authentication and signup behavior, tune passwords and sessions, and enable optional features as needed.
- Required setup: application/database, root secret, browser origins and email delivery.
- Authentication and sessions: login methods, signup, OAuth providers, lifetimes and 2FA.
- Passwords and security: password policy, hashing, pepper keys and concurrency.
- Request protection: rate limits and trusted proxy configuration.
- Organizations and permissions: optional organizations, app roles and organization roles.
- Operations and customization: logging, audit, cleanup and email templates.
Build a configuration
All settings go through NewConfig(opts ...Option). It applies options in the
order supplied, fills defaults and returns all validation problems joined into
one error. Pass the result to New to initialize go-auth.
This production example configures the required sections. It uses the default
bcrypt hasher and password login; signup remains closed until you explicitly
configure WithRegistration.
cfg, err := goauth.NewConfig(
goauth.WithApp(goauth.AppConfig{
Name: "MyApp",
BaseURL: "https://app.example.com",
Database: goauth.DatabaseConfig{
Driver: goauth.DriverPostgres,
URL: os.Getenv("DATABASE_URL"),
},
Environment: goauth.EnvironmentProd,
}),
goauth.WithSecret(os.Getenv("AUTH_SECRET")),
goauth.WithBrowser(goauth.BrowserConfig{
APIURL: "https://api.example.com",
AllowedOrigins: []string{"https://app.example.com"},
}),
goauth.WithEmail(goauth.EmailConfig{
Host: os.Getenv("SMTP_HOST"),
Port: 587,
From: "auth@example.com",
User: os.Getenv("SMTP_USER"),
Pass: os.Getenv("SMTP_PASS"),
TLS: goauth.TLSStart,
}),
)
if err != nil {
return err
}The snippet uses os and
goauth "github.com/nazimdjebloun/go-auth". Supply a database URL, a persistent
root secret of at least 32 bytes, and SMTP settings through the environment.
Apply auth migrations before calling New; construction
checks migration compatibility but never applies migrations. See
Installation for database driver registration and
first working application for a complete server.
Defaults, replacement and option order
- Omit optional sections to keep their defaults. Required fields and validation errors are listed in the reference below.
- Options that assign a grouped configuration, including
WithSecurity,WithPasswordHashing,WithAuthenticationandWithRegistration, replace that entire group. Put all intended fields for a group in one call; a later call resets omitted fields according to that group's defaults. - Apply broad configuration before narrow overrides. For example, call
WithRateLimitbeforeWithRateLimitRoute; a later broad call replaces the earlier rate-limit table. Newclones the configuration, reapplies defaults and validates the complete result before deriving keys or initializing services. Options applied afterNewConfigare checked again; startup does not modify the caller's config.
Required setup
WithApp: Required
App identity, the database connection, and the deployment environment.
goauth.WithApp(goauth.AppConfig{
Name: "MyApp", // required
BaseURL: "https://myapp.com", // required
Database: goauth.DatabaseConfig{ // required
URL: os.Getenv("DATABASE_URL"), // one of URL / DB / Pool: connection string
// DB: preOpenedSQLDB, // one of URL / DB / Pool: pre-opened *sql.DB, alternative to URL
// Pool: preOpenedPgxPool, // one of URL / DB / Pool: PostgreSQL only, alternative to URL
Driver: goauth.DriverPostgres, // required: DriverPostgres | DriverSQLite | DriverMySQL
// Optional pool settings: apply only to URL-created connections.
// Borrowed DB / Pool keep the caller's settings. Values below use defaults.
MaxOpenConns: 0, // 0 selects 25 for PostgreSQL/MySQL, 1 for SQLite; negative invalid
MaxIdleConns: nil, // nil selects min(2, resolved MaxOpenConns); *int allows explicit 0 (no idle retention)
ConnMaxLifetime: 0, // time.Duration; 0 disables recycling by age; negative invalid
ConnMaxIdleTime: 0, // time.Duration; 0 disables recycling by idle time; negative invalid
// An explicit MaxIdleConns must be between 0 and resolved MaxOpenConns.
// For private SQLite :memory:, retain one idle connection and leave recycling disabled.
},
Environment: goauth.EnvironmentProd, // optional, default EnvironmentProd
})| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
Name | string | Required | none | Shown in email templates. |
BaseURL | string | Required | none | Must be a valid http:// or https:// URL. Base for links in emails. |
Database | DatabaseConfig | Required | none | See table below. |
Environment | Environment | Optional | EnvironmentProd | One of dev, staging, prod (development/production are accepted as aliases). Drives the cookie Secure default and the email-link http:// default. |
Database (DatabaseConfig)
| Field | Type | Required | Notes |
|---|---|---|---|
URL | string | One of URL / DB / Pool | Connection string. The library opens and closes the connection itself. |
DB | *sql.DB | One of URL / DB / Pool | Pre-opened; the library borrows it and never closes it. |
Pool | *pgxpool.Pool | One of URL / DB / Pool | PostgreSQL only. Pre-opened; borrowed, not closed. |
Driver | Driver | Required | One of DriverPostgres, DriverSQLite, DriverMySQL. |
MaxOpenConns | int | Optional | URL-created databases only. Zero selects 25 for PostgreSQL/MySQL or 1 for SQLite. Negative values are rejected. |
MaxIdleConns | *int | Optional | URL-created databases only. Nil selects min(2, MaxOpenConns) after defaults; pointer to zero disables idle retention. Must be between zero and the resolved open limit. |
ConnMaxLifetime | time.Duration | Optional | URL-created databases only. Zero disables lifetime recycling; negative values are rejected. |
ConnMaxIdleTime | time.Duration | Optional | URL-created databases only. Zero disables idle-time recycling; negative values are rejected. |
Each Auth instance created from a URL owns one bounded sql.DB; PostgreSQL no longer creates a second pgx pool. Budget connections across all application instances and other database clients. Once the limit is reached, operations wait for a free connection until their context expires. These defaults are starting points, not measured capacity recommendations.
Borrowed DB and Pool retain the caller's connection settings and remain open after Auth.Close() or failed construction. For a borrowed pgx pool, the library closes only its own database/sql adapter; the adapter does not retain idle connections. Configure the supplied pgx pool directly. Budget fields are validated even when a borrowed handle makes them inapplicable.
SQLite defaults keep one idle connection with no lifetime recycling, preserving an in-memory database between operations. For private :memory: databases, keep one open connection, retain it idle, and leave lifetime limits disabled. Increasing connections creates independent private databases; zero idle retention or connection recycling can destroy the database. Use a file-backed database for durable data. Constructor failures release library-owned SQL handles; successful instances release them on Auth.Close().
New checks connectivity and recorded migration compatibility for every database source before services or workers start, within a shared ten-second startup timeout. Missing, dirty, unknown, checksum-mismatched or incompatible history rejects construction. Borrowed handles remain caller-owned on failure. The former DatabaseConfig.CheckSchema field is removed; this check is always enabled.
Run auth migrations explicitly with deployment credentials. New never executes migration DDL; use VerifySchema during deployment for structural verification.
Expected errors
app_name cannot be empty:Namewas left blank.base_url is required:BaseURLwas left blank.base_url must be a valid HTTP or HTTPS URL:BaseURLdoes not parse ashttp://orhttps://.database: driver cannot be empty:Driverwas left blank.database: one of URL, DB, or Pool is required: none of the three connection fields were set.environment must be one of dev, staging, or prod, got "...":Environmentwas set to something other than the recognized values. Leaving it blank is fine (proddefault).
WithSecret: Required
The app-wide root secret from which purpose-specific cryptographic keys are derived.
goauth.WithSecret(os.Getenv("AUTH_SECRET"))| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
secret | string | Required | none | Minimum 32 bytes. HKDF derives separate CSRF, 2FA-binding, OAuth-encryption, OTP-pepper, Redis snapshot-encryption, and Redis key-name keys from it. Source it from the environment; never commit it. Password peppering uses a separate opt-in secret. |
When rotating the root secret, set SecretRotatedAt in your existing WithSecurity configuration to the UTC moment the new WithSecret value became active. There is one rotation timestamp; it records an operator action and does not rotate keys itself.
SecurityConfig.SecretRotatedAt is optional and defaults to unset (zero): timestamp-based expiry classification stays disabled and verification is purely by HMAC. Set it to the same UTC value on every instance when you rotate the secret. Pre-rotation 2FA, set-password, and delete-account codes then fail with their expiry error rather than their invalid-code error. Email verification instead looks up the code using the current pepper; a code hashed under the old pepper returns code_invalid. Request a new code in either case. Source the timestamp like the secret itself: one shared environment variable or secrets-manager entry, never the instance's boot time or per-instance files that can drift.
SecretRotatedAt governs short-lived code expiry classification only. Password peppering is independent and uses versions and keys under WithPasswordHashing; it has no rotation timestamp. Rotating WithSecret does not invalidate password hashes. See secret rotation.
Expected errors
secret: signing secret is required: nothing was passed.secret: signing secret must be at least 32 bytes for HMAC-SHA256: shorter than 32 bytes.
WithBrowser: Required (AllowedOrigins)
Configure the public API origin, exact frontend origins, and browser cookie delivery together. Session lifetimes stay in WithSession; password and email-link policy stay in WithSecurity.
goauth.WithBrowser(goauth.BrowserConfig{
APIURL: "https://api.example.com",
AllowedOrigins: []string{
"https://app.example.com",
"https://admin.example.com",
},
Cookies: goauth.CookieConfig{
Name: "goauth_session",
RefreshName: "goauth_refresh",
ChallengeName: "_2fa_challenge",
AdminName: "goauth_admin_session",
AdminRefreshName: "goauth_admin_refresh",
AdminChallengeName: "_goauth_admin_2fa_challenge",
Domain: "", // host-only; recommended
Path: "/",
SameSite: http.SameSiteLaxMode,
Secure: nil, // derive from APIURL and Environment
},
})Import net/http when setting SameSite. All cookie fields can be omitted.
| Field | Default | Meaning |
|---|---|---|
APIURL | Origin of AppConfig.BaseURL | Public HTTP(S) API origin; set explicitly when the API has a different origin. No path, query, fragment, or credentials. |
AllowedOrigins | Required | Exact frontend origins, including scheme and port. At least one; no wildcards. Drives CORS and mutation origin checks. |
Cookies.Name | goauth_session | Session cookie name. |
Cookies.RefreshName | goauth_refresh | Refresh cookie name. |
Cookies.ChallengeName | _2fa_challenge | Mandatory 2FA browser binding cookie name. |
Cookies.AdminName | goauth_admin_session | Independent admin session cookie name. |
Cookies.AdminRefreshName | goauth_admin_refresh | Independent admin refresh cookie name. |
Cookies.AdminChallengeName | _goauth_admin_2fa_challenge | Independent admin 2FA browser binding cookie name. |
Cookies.Domain | Empty (host-only) | Session/refresh scope only. The browser must accept it for the API host; keep empty unless sharing those credentials deliberately. |
Cookies.Path | / | Must be / to cover the built-in /auth and /admin routes. |
Cookies.SameSite | Lax | Applies to session, refresh, challenge, CSRF, and anonymous browser binding cookies. |
Cookies.Secure | Derived | nil: false only for an HTTP API in EnvironmentDev; true otherwise. SecureAlways() forces true; SecureNever() is development-only. |
All these cookies are HttpOnly. Both contexts inherit the same Domain, Path, SameSite, Secure, and session lifetime policy. CSRF cookies (_csrf and _goauth_admin_csrf), anonymous browser binding cookies, and challenge binding cookies are always host-only at /; they do not inherit Cookies.Domain. All six configurable names must be valid and distinct and must not collide with reserved CSRF/binding names. Cookie prefix requirements are validated.
Independent app and admin sessions
Both frontends can call one API host directly in the same browser. Ordinary requests select the app context by default. The admin client sends X-GoAuth-Session: admin on every request, including CSRF bootstrap, login, 2FA, reads, refresh, and logout. The built-in admin login route always selects admin; follow-up requests still need the header. Explicit app is also accepted; unknown or multiple selector values return 400 invalid_session_context, as does explicit app on admin login.
The selector chooses a cookie and CSRF context; it grants no permissions. Role, protected-admin, permission, and 2FA checks still apply. There is no fallback to the other context's cookies. Ordinary login/logout/refresh only affect the selected context. Account-wide operations such as revoking all sessions and password reset retain their existing scope.
Staging and production require HTTPS API/frontend origins and secure cookies. HTTP development remains supported with Lax cookies. SameSite=None requires secure cookies including in development; use local HTTPS for genuinely cross-site development. Allowing HTTP links in email is a separate WithSecurity setting.
Mandatory CSRF contract
There is no switch to disable double-submit, origin checking, or 2FA browser binding, and no application-level CSRF token overrides. Ordinary unsafe built-in HTTP routes and Auth.RequireCSRF require both:
- An allowed
Origin, or an allowedRefererwhenOriginis absent. The middleware also accepts the effective API request origin itself. Missing both is rejected. - The selected context's signed CSRF cookie and matching
X-CSRF-Tokenheader, bound to this anonymous browser or stable authenticated session ID. App and admin tokens are not interchangeable.
GET /auth/csrf-token always returns 200 {"token":"..."} with Cache-Control: no-store. The frontend keeps that token in memory, sends credentials, and echoes it on mutations. Login, logout, password changes, OAuth linking/unlinking, and 2FA setting changes issue a replacement in the X-CSRF-Token response header, exposed through CORS. Ordinary requests and refresh do not rotate CSRF unnecessarily. OAuth callbacks retain their protocol-specific state binding, including POST callbacks.
The JSON bootstrap works unchanged for proxied apps and sibling subdomains. Different sites additionally need SameSite=None and HTTPS, and remain subject to browser third-party-cookie restrictions. See Deployment and Client setup.
WithMailer / WithEmail / WithTemplates: required unless every email feature is off
A mailer is required whenever some configured feature actually sends email: InviteSignup, RequireEmailVerification, TwoFactorConfig.RequireEmail2FA, TwoFactorConfig.DefaultEnabled, or mandatory admin-account two-factor authentication (on by default; see Admin login and Security). Set TwoFactorConfig.DisableAdminTwoFactor to drop the last one; with all five off, an API-only deployment needs no mailer at all. NewConfig's validation error names exactly which enabled feature is still requiring one.
Exception: EnvironmentDev. If neither WithMailer nor WithEmail is set and Environment is EnvironmentDev, NewConfig automatically defaults to a log-only mailer (mailer.Log) instead of erroring. Emails are logged (slog) rather than delivered, so local development works with zero mailer setup. This default only fills in when nothing was configured; an explicit WithMailer/WithEmail call is never overridden. Outside EnvironmentDev, no default is applied: a still-unconfigured mailer is a validation error.
// Built-in SMTP transport:
goauth.WithEmail(goauth.EmailConfig{
Host: os.Getenv("SMTP_HOST"), // required
Port: 587, // required, 1-65535
From: "auth@myapp.com", // required, must be a valid email address
User: os.Getenv("SMTP_USER"), // optional: must be set together with Pass, or both empty
Pass: os.Getenv("SMTP_PASS"), // optional: same pairing rule as User
TLS: goauth.TLSStart, // optional, default TLSStart (STARTTLS; TLSStart | TLSImplicit | TLSNone)
})
// Or your own implementation of port.Mailer (Resend, Postmark, SES, ...):
goauth.WithMailer(myMailer)
// Dev-only: logs emails instead of sending them. NewConfig rejects this
// outside EnvironmentDev. It is also what EnvironmentDev defaults to
// automatically when no mailer is configured at all.
goauth.WithMailer(mailer.NewLog(nil)) // mailer is github.com/nazimdjebloun/go-auth/mailer; nil logger uses slog.Default()
// Optional: replace the rendered email content, not just delivery.
goauth.WithTemplates(myTemplateProvider)| Option | Purpose | Required | Notes |
|---|---|---|---|
WithMailer(port.Mailer) | Your own delivery implementation | Required unless every email-sending feature is off (auto-defaulted in EnvironmentDev, see above) | Takes precedence over WithEmail if both are set. |
WithEmail(EmailConfig) | Built-in SMTP delivery | Required unless every email-sending feature is off (auto-defaulted in EnvironmentDev, see above) | Ignored if WithMailer is also set: its fields are not even validated in that case. |
WithTemplates(port.TemplateProvider) | Replace the built-in email templates | Optional | The library still renders subject/HTML/text and hands finished strings to the mailer either way. This replaces content; WithMailer replaces delivery. |
TwoFactorConfig.DisableAdminTwoFactor | Opt out of mandatory admin-account 2FA; global/per-user requirements remain | Optional, default false (challenge on) | Set via WithTwoFactor. The last of the five mailer-requiring features to disable for a mailer-free, API-only deployment. |
Public password-reset and verification-resend requests are delivered by the durable recovery worker. Its processing context has a 30-second deadline; custom port.Mailer implementations must honor context cancellation and deadlines so retries and Auth.Close can complete. The recovery queue table is required even when audit is disabled.
mailer.NewAsync optionally adds an in-memory queue to a mailer. AsyncConfig.Retries must be nonnegative; zero still makes one delivery attempt. Queued sends return before delivery, while a full queue falls back to synchronous delivery and returns any final send error. Call Async.Stop(ctx) separately during shutdown: its context bounds your wait, and workers continue draining if that wait expires. Repeated calls wait for the same drain. There is no default shutdown deadline; AsyncConfig.Timeout bounds each underlying send attempt (30 seconds by default).
EmailConfig (only validated when WithEmail is set and WithMailer is not)
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
From | string | Required | none | Must be a valid email address. |
Host | string | Required | none | |
Port | int | Required | none | 1–65535. |
User | string | Optional | none | Must be set together with Pass, or both left empty. |
Pass | string | Optional | none | Same pairing rule as User. |
TLS | TLSMode | Optional | TLSStart | One of TLSStart (STARTTLS, typically port 587) or TLSImplicit (typically port 465). TLSNone is plaintext and is accepted only in EnvironmentDev. |
Expected errors
email: Mailer or Email config required — enabled features need it: <reasons>: reachable in any environment exceptEnvironmentDev, where the log-mailer default fills this in automatically. Empty only when no configured feature actually sends email, in which case there is no error at all.email: host is requiredemail: port must be between 1 and 65535, got Nemail: from address is requiredemail: from address "..." is not valid: ...email: user and pass must both be set or both be emptyemail: tls mode must be one of TLSNone, TLSStart, or TLSImplicit, got Nemail: TLSNone is only allowed in EnvironmentDev — use TLSStart or TLSImplicit outside developmentmailer: mailer.Log cannot be used outside EnvironmentDev — codes and reset links would be written to application logs instead of delivered: an explicit log mailer was configured in a non-dev environment.
Authentication and sessions
WithAuthentication: Optional, replaces wholesale
Select which methods can authenticate existing and new accounts. Omit this
section for email/password authentication only. Supplying it selects both
booleans exactly as written; there are no pointer booleans or goauth.Bool helpers.
goauth.WithAuthentication(goauth.AuthenticationConfig{
EmailPassword: true, // default when the section is omitted
OAuth: false, // default
})| Field | Type | Default | Effect |
|---|---|---|---|
EmailPassword | bool | true | Allows password login, including admin login, password setup/change/reset, and password-based administrative account creation. |
OAuth | bool | false | Allows login and account linking through configured providers. Requires at least one WithProvider. |
Disabling a method retains accounts and credentials. It does not revoke existing sessions. Completion checks use the current policy: outstanding password reset or setup tokens cannot complete with password authentication disabled, and an OAuth callback cannot complete with OAuth authentication disabled.
Both methods disabled is invalid. A signup method also needs its corresponding authentication method. Registering a provider does not enable OAuth by itself.
WithRegistration: Optional, replaces wholesale
Select how new accounts can be created. Registration is closed by default.
Authentication for existing accounts is controlled separately by
WithAuthentication; the email-verification requirement also applies to
existing unverified accounts and sessions.
goauth.WithRegistration(goauth.RegistrationConfig{
AllowPublic: false,
EmailPassword: false,
OAuth: false,
InviteSignup: false,
RequireEmailVerification: false,
InviteTTL: 7 * 24 * time.Hour,
VerificationCodeTTL: 15 * time.Minute,
VerificationResendInterval: time.Minute,
})| Field | Type | Default | Effect |
|---|---|---|---|
AllowPublic | bool | false | Opens signup without an account invitation. |
EmailPassword | bool | false | Allows password account creation through public signup or signup invitations. |
OAuth | bool | false | Allows provider account creation through public signup or signup invitations. |
InviteSignup | bool | false | Enables account-creation invitations. Organization membership invitations remain independent. Requires email delivery. |
RequireEmailVerification | bool | false | Requires verified email before login or protected access, including existing sessions. Requires email delivery. |
InviteTTL | time.Duration | 7d | Account invitation lifetime. |
VerificationCodeTTL | time.Duration | 15m | Email verification code lifetime. |
VerificationResendInterval | time.Duration | 60s | Minimum resend interval. A throttled resend returns codeSent: false. Negative disables the minimum. |
Each supplied boolean is literal. Omitted booleans are false; durations left at zero use their documented defaults. Public or invitation signup requires at least one signup method, and that method must be enabled for authentication. Closing signup does not prevent an already-created account from finishing email verification.
For existing password accounts with OAuth-only public signup, enable both
methods in WithAuthentication, then set registration AllowPublic: true,
OAuth: true, and EmailPassword: false. Configure a provider explicitly.
For strict OAuth-only access, disable authentication EmailPassword too.
Email verification for existing sessions
Enabling RequireEmailVerification affects every unverified account, regardless
of when it was created. An existing session is retained but protected requests
return 403 {"error":"email_not_verified","message":"Please verify your email first"}.
GET /auth/me instead returns only id, email, isVerified: false, and
step: "verify_email". Use that response to show the signup verification
screen before rendering protected content. Verification, resend and logout
remain available. Refresh does not grant access around the verification check.
POST /auth/refresh still rotates cookies but returns only
step: "verify_email" and the email while verification is pending; no
session object is included. After verification, the retained session can access
protected routes again.
Disabling the requirement removes the restriction for all accounts without
erasing their stored verification status. Already-verified users are not asked
to verify again. Page loads do not automatically send another email.
Expected startup errors
authentication: enable EmailPassword or OAuth.authentication: OAuth requires at least one WithProvider.registration: EmailPassword requires authentication.EmailPassword.registration: OAuth requires authentication.OAuth.- Public or invitation signup without any registration method.
registration: invite_ttl must be positiveandregistration: verification_code_ttl must be positivefor negative values.email: Mailer or Email config required — enabled features need it: <reasons>. The reasons identifyInviteSignup,RequireEmailVerification,RequireEmail2FA,DefaultEnabled, or default admin two-factor policy. Development supplies the log mailer when no mailer or SMTP is configured.
WithProvider: Optional
Registers an OAuth provider. Call it once per provider and enable
AuthenticationConfig.OAuth explicitly. Registration OAuth independently
controls whether the provider may create new accounts.
goauth.WithProvider(github.New(github.Config{
ClientID: os.Getenv("GITHUB_CLIENT_ID"),
ClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"),
RedirectURL: "https://myapp.com/auth/oauth/github/callback",
}))| Requirement | Notes |
|---|---|
| Non-nil | A nil provider is rejected. |
Non-empty Name() | Each provider reports its own name. |
Unique Name() | Registering two providers under the same name is rejected. |
Expected errors
provider: nil provider registered via WithProvider.provider: provider with empty name.provider: duplicate provider "...".provider "...": client_id is required: the provider's OAuth2 config has no client ID.provider "...": client_secret is required: the provider's OAuth2 config has no client secret.
WithSession: Optional
Session and refresh-token lifetimes.
goauth.WithSession(goauth.SessionConfig{
TTL: 30 * 24 * time.Hour, // optional, default 30d
IdleTTL: 7 * 24 * time.Hour, // optional, default 7d
RefreshTokenTTL: 30 * 24 * time.Hour, // optional, default 30d
TokenTTL: time.Hour, // optional, default 1h
MaxLifetime: 0, // optional, default 0 (no limit)
GraceWindow: goauth.Duration(10 * time.Second), // optional, default 5s
TouchDebounce: goauth.Duration(0), // optional, default 5m (0 turns it off)
})| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
TTL | time.Duration | Optional | 30d | Absolute hard expiry. |
IdleTTL | time.Duration | Optional | 7d | Timeout since last activity. Must not exceed TTL. |
RefreshTokenTTL | time.Duration | Optional | 30d | Must not be less than TTL. |
TokenTTL | time.Duration | Optional | 1h | Lifetime of email verification and password reset tokens. |
MaxLifetime | time.Duration | Optional | 0 (no limit) | If set, must be >= TTL. |
GraceWindow | *time.Duration | Optional | 5s | Window a just-rotated refresh token is still accepted, so two requests racing to refresh do not log the user out. nil (the zero value; leave the field out) means "use the default"; a non-nil pointer is used exactly as given, including goauth.Duration(0) to turn it off. Set it with goauth.Duration(...), since Go cannot take the address of a duration literal directly. |
TouchDebounce | *time.Duration | Optional | 5m | Minimum interval between last_active_at writes. Same nil-vs-pointer rule as GraceWindow; goauth.Duration(0) writes on every authenticated request. |
GraceWindow and TouchDebounce are pointers specifically so "left unset" and "explicitly zero" cannot collide. With a plain time.Duration field, GraceWindow: 0 and simply not mentioning GraceWindow at all are the same struct, so one of the two meanings would always win by accident. A *time.Duration field can be nil (unset, uses the default) or point at 0 (off) as two different values.
Expected errors
session_ttl must be positivesession_idle_ttl must be positivesession_idle_ttl must not exceed session_ttlrefresh_token_ttl must be positiverefresh_token_ttl must not be less than session_ttlsession token_ttl must be positive: only reachable via an explicit negative value; an omitted one defaults to1h.session grace_window must not be negative (use goauth.Duration(0) to turn it off): a negative duration was passed.session touch_debounce must not be negative (use goauth.Duration(0) to turn it off): same.session max_lifetime must not be negative (0 = no limit)session max_lifetime must not be less than session_ttl: only checked whenMaxLifetimeis set above zero.
WithTwoFactor: Optional
Email two-factor settings are separate from browser transport and password policy. Challenge binding is mandatory; its cookie name lives at BrowserConfig.Cookies.ChallengeName.
goauth.WithTwoFactor(goauth.TwoFactorConfig{
RequireEmail2FA: false, // optional, default false
DefaultEnabled: false, // optional, default false
CodeTTL: 5 * time.Minute, // optional, default 5m
DisableAdminTwoFactor: false, // optional, default false
})| Field | Type | Default | Notes |
|---|---|---|---|
RequireEmail2FA | bool | false | Makes email 2FA mandatory for password and OAuth authentication, including signup completion where email verification does not take precedence. |
DefaultEnabled | bool | false | Seeds User.TwoFactorEnabled for new password, invitation, and OAuth accounts; users may still opt out. Does not update existing accounts. |
CodeTTL | time.Duration | 5m | Lifetime of a 2FA login code. |
DisableAdminTwoFactor | bool | false | Opts out of mandatory admin-account 2FA across password/OAuth login and admin HTTP access. Global/per-user requirements still apply. |
With app permissions enabled, admin-account 2FA applies only to the protected
admin role. Custom roles use non-admin settings even when they have delegated
administrative permissions. RequireEmail2FA and per-user TwoFactorEnabled
also require session assurance for their library administrative actions.
Expected errors
two_factor: code_ttl must be positive: only reachable via an explicit negative value; an omitted one defaults to5m.
Passwords and security
WithSecurity: Optional
Password policy, optional email changes, email-link transport policy, and application-secret rotation metadata remain separate from browser transport.
goauth.WithSecurity(goauth.SecurityConfig{
PasswordPolicy: domain.PasswordPolicy{ // optional; all-zero policy selects defaults
MinLength: 12, // optional, default 8
MaxLength: 128, // optional, default 128
RequireLetter: true, // optional; default true for an all-zero policy
RequireUppercase: true, // optional; default false for an all-zero policy
RequireDigit: true, // optional; default true for an all-zero policy
RequireSpecial: false, // optional; default false for an all-zero policy
},
AllowEmailChange: false, // optional, default false; enabling requires a mailer
AllowHTTPURLs: goauth.RequireHTTPSEmailLinks(), // optional, default nil: derive from environment
SecretRotatedAt: time.Time{}, // optional, default zero: no rotation timestamp recorded
// On a WithSecret rotation, set SecretRotatedAt to when the new secret became active (UTC).
// This metadata does not rotate a key and is unrelated to password-pepper versions.
})An entirely zero password policy selects 8..128 Unicode code points with a letter and a digit. A nonzero policy preserves every boolean literally; for example, PasswordPolicy{MinLength: 12} is length-only, with no implicit letter or digit rule. Zero lengths resolve to 8 and 128. Negative lengths, inverted ranges and maxima too short for the required character classes fail configuration validation.
The selected hasher may impose an additional byte bound. Unpeppered bcrypt accepts at most 72 input bytes and reports excess as weak_password; Argon2id has no bcrypt-derived bound. With password peppering, that algorithm bound applies to the derived 44-byte representation, not the raw password. A minimum that cannot fit a known unpeppered hasher limit fails startup. Custom hashers can declare their bound with port.PasswordInputLimiter; the construction probe stays within that bound. Typed-nil hashers and negative bounds are rejected. These checks govern new password writes, not verification of existing credentials.
AllowHTTPURLs defaults to true in development and false elsewhere and governs rendered email links only. SecretRotatedAt defaults to zero; see secret rotation. Each WithSecurity call replaces this whole group: include the timestamp alongside your other security settings in one call.
Email changes
goauth.WithSecurity(goauth.SecurityConfig{AllowEmailChange: true})AllowEmailChange defaults to false. Enabling requires a configured mailer and mounts
four authenticated email-change routes. The Services().Email capability is
nil when disabled; facade calls return email_change_disabled.
Include this field alongside your other settings in the same WithSecurity
call. Each call replaces the whole SecurityConfig section.
Confirmation requires both mailbox codes in the initiating session. Fixed limits are ten minutes total, five failed confirmation attempts, three starts per account per hour, and three resends per mailbox with a one-minute cooldown. Replacement and cancellation preserve the hourly start budget. Route-level IP limits apply in addition; see email changes. Apply auth migration revision 4 before starting this library version.
WithPasswordHashing: Optional
Password hashing defaults to bcrypt at cost 12. WithPasswordHashing groups the algorithm, bcrypt cost, optional password pepper and resource limits. Hasher replaces bcrypt for new writes and rehash-on-login; BcryptCost only applies when Hasher is nil. Password policy stays in WithSecurity.
The following example shows every hashing field, including optional values and
the complete Argon2id profile. Argon2id itself is optional; when constructing an
explicit argon2id.Options, all five fields must be valid. Omitted fields in
that struct are not filled from DefaultOptions().
goauth.WithPasswordHashing(goauth.PasswordHashingConfig{
// Optional; default nil selects built-in bcrypt.
// This example opts into Argon2id using the DefaultOptions profile.
Hasher: argon2id.New(argon2id.Options{
Memory: 65536, // required for explicit Options; profile: 65536 KiB (64 MiB)
Iterations: 3, // required for explicit Options; profile: 3 passes
Parallelism: 4, // required for explicit Options; profile: 4 lanes
SaltLength: 16, // required for explicit Options; profile: 16 bytes
KeyLength: 32, // required for explicit Options; profile: 32 bytes
}),
BcryptCost: 12, // optional, default 12; ignored while Hasher is non-nil
// Optional; the zero-valued group leaves new passwords unpeppered.
Pepper: goauth.PasswordPepperConfig{
CurrentVersion: 0, // optional, default 0: no pepper for new writes
Keys: nil, // optional, default nil; retain keys needed by stored versions
},
Concurrency: 4, // optional, default 4; 0 also selects 4; ignored with WorkLimiter
WorkLimiter: nil, // optional, default nil: a private limiter per Auth
})Import it as github.com/nazimdjebloun/go-auth/hasher/argon2id. DefaultOptions exactly matches RFC 9106 section 4's memory-constrained recommendation: 64 MiB, 3 iterations, parallelism 4, a 16-byte random salt, and a 32-byte derived key. Argon2id's memory is consumed per concurrent hash or comparison, so benchmark and capacity-plan on the deployment hardware before enabling it.
| Field | Type | Default | Notes |
|---|---|---|---|
Hasher | port.Hasher | nil (built-in bcrypt) | Used by registration, password changes, resets and rehash-on-login. Hash output must carry a stable identifying prefix such as $argon2id$; New rejects a hasher whose output cannot self-identify. Use argon2id.New(argon2id.DefaultOptions()) for the built-in Argon2id choice. |
BcryptCost | int | 12 | Zero selects 12. Existing bcrypt rows retain their own verification cost; a successful login upgrades stale parameters. Ignored when Hasher is non-nil. |
Pepper | PasswordPepperConfig | disabled (CurrentVersion: 0) | Optional versioned HMAC layer before the selected KDF. Its fields are CurrentVersion uint32 and Keys map[uint32]string. See password pepper for rollout and rotation. |
Concurrency | int | 4 active operations | Zero selects four; negative values fail validation even with a shared limiter. Capacity is private to each Auth unless WorkLimiter is supplied. |
WorkLimiter | *hasher.WorkLimiter | nil | Shares capacity across Auth instances. A non-nil limiter takes precedence over Concurrency. Construct it with hasher.NewWorkLimiter. |
An omitted or zero-valued hashing group selects bcrypt cost 12, no password pepper, and a private four-operation limiter. Each WithPasswordHashing call replaces the whole group: include all intended fields in one call. The pepper map is copied when the option is applied and when configuration is cloned; live Hasher and WorkLimiter objects retain their identity.
This replaces the removed WithPasswordHasher, WithBcryptCost, WithPasswordPepper, WithPasswordConcurrency and WithPasswordWorkLimiter options. Move their arguments into the corresponding fields in one WithPasswordHashing call. Splitting fields over multiple calls would reset earlier fields to defaults. Hash formats, stored parameter verification, pepper derivation, rehashing and overload behavior are unchanged by this grouping.
Expected error
bcrypt_cost N exceeds bcrypt.MaxCost (31):BcryptCostwas set above bcrypt's supported maximum while no custom hasher was configured.
port.Hasher deliberately remains a two-method interface:
type Hasher interface {
Hash(password string) (string, error)
Compare(password, hash string) error
}The stored string identifies the verifier. Hash must return a salted, one-way password hash whose first format field is stable for the algorithm, and Compare must parse and verify that algorithm's stored parameters. By default the implementation receives the caller's password directly. For a versioned pepper row it receives the base64-encoded HMAC-SHA256 representation selected by users.password_pepper_version; a NULL version receives the raw password. go-auth always retains bcrypt and Argon2id verifiers for rows written before an algorithm switch. See Security → Password hashing and hasher migration for the dispatch and rehash guarantees.
Password work limits
Every Auth shares one limiter across password hashing, verification, dummy
verification and startup probes. The default allows four active operations.
PasswordHashingConfig{Concurrency: 2} lowers that to two; zero selects four and negative
values fail NewConfig and startup validation. There is no waiting queue:
excess work returns domain.ErrPasswordWorkBusy, exposed by HTTP as
503 {"error":"password_work_busy","message":"Password processing is busy; try again later"}.
The caller can retry later with backoff. Existing route/IP rate limits still apply.
The limit covers HTTP and programmatic service calls, bcrypt, Argon2id, custom hashers and legacy algorithms selected from stored hashes. Capacity is held until the underlying synchronous operation finishes, including when the request has been canceled. Optional rehash-on-login skips an overloaded upgrade after successful verification, leaving login usable and the credential unchanged.
Reusing one Config creates independent limiters. To share one process budget
across multiple Auth instances, import
github.com/nazimdjebloun/go-auth/hasher, construct a limiter once, and supply the
same pointer to each instance:
limiter, err := hasher.NewWorkLimiter(4)
if err != nil {
return err
}
// Include this complete hashing group when building each instance's config.
// Reuse the same limiter pointer across those instances.
option := goauth.WithPasswordHashing(goauth.PasswordHashingConfig{
// Optional; default nil selects built-in bcrypt. Argon2id is selected here.
Hasher: argon2id.New(argon2id.Options{
Memory: 65536, // required for explicit Options; profile: 65536 KiB (64 MiB)
Iterations: 3, // required for explicit Options; profile: 3 passes
Parallelism: 4, // required for explicit Options; profile: 4 lanes
SaltLength: 16, // required for explicit Options; profile: 16 bytes
KeyLength: 32, // required for explicit Options; profile: 32 bytes
}),
BcryptCost: 12, // optional, default 12; ignored while Hasher is non-nil
// Optional; these values leave new passwords unpeppered.
Pepper: goauth.PasswordPepperConfig{
CurrentVersion: 0, // optional, default 0: no pepper for new writes
Keys: nil, // optional, default nil; retain keys needed by stored versions
},
Concurrency: 4, // optional, default 4; ignored because WorkLimiter is supplied
WorkLimiter: limiter, // optional, default nil; supplied limiter owns the shared capacity
})NewWorkLimiter(0) selects four; negative values are rejected. A zero-value
hasher.WorkLimiter is invalid. A nil WorkLimiter selects private capacity;
Auth.Close does not close a shared limiter. The budget is local to the process,
not distributed across replicas. Direct calls to a standalone KDF, such as
argon2id.New(...).Hash, are outside the Auth registry and its limit.
This bounds active operations, not process RAM. At 64 MiB per Argon2id operation, four active operations need roughly 256 MiB of KDF working memory. Stored hashes use their own parameters and may cost more. Leave headroom for the application, garbage collection and completed allocations awaiting reclamation; benchmark with the largest stored memory cost you accept before choosing a concurrency.
Password pepper: Optional
Password peppering is off by default. Enable it with independently managed secret material:
goauth.WithPasswordHashing(goauth.PasswordHashingConfig{
Hasher: nil, // optional, default nil: built-in bcrypt
BcryptCost: 12, // optional, default 12; used because Hasher is nil
// Optional; this example enables peppering for new password writes.
Pepper: goauth.PasswordPepperConfig{
CurrentVersion: 1, // optional, default 0 (off); select a key version to enable
// Optional when peppering is off; must contain the selected active version.
// Default nil. Keep every version still referenced by stored passwords.
Keys: map[uint32]string{
1: os.Getenv("AUTH_PASSWORD_PEPPER_V1"), // at least 32 bytes of independent secret material
},
},
Concurrency: 4, // optional, default 4; 0 also selects 4
WorkLimiter: nil, // optional, default nil: a private limiter using Concurrency
})CurrentVersion selects the key used by every new password write. Version 0 is reserved for unpeppered rows: omitting this option leaves CurrentVersion at zero and skips HMAC entirely, so the selected KDF receives the password directly. Each non-zero key must be at least 32 bytes, identical on every application instance, and stored separately from the password database (preferably in a secrets manager). The option copies the map; later caller mutations do not change live configuration.
For a versioned row, go-auth derives that version's HMAC key with HKDF purpose goauth-password-pepper-v1, computes base64(HMAC-SHA256(derivedKey, password)), and passes the result to bcrypt or Argon2id. The nullable users.password_pepper_version column selects exactly one key during verification; the library never tries every configured key. Existing NULL rows verify as ordinary unpeppered passwords and upgrade to CurrentVersion through guarded rehash-on-login.
Rotate without downtime in phases:
- Deploy the old
CurrentVersioneverywhere with both the old and next keys inKeys. - After every instance has the next key, deploy the next
CurrentVersionwhile retaining both keys. Successful logins upgrade old rows lazily. - Remove the old key only after no database row references its version.
New()queries the distinct stored versions whenever a keyring is configured and refuses startup if any referenced version is missing. Its database ping and this query share a 10-second startup deadline.
Preloading is supported even before first opt-in: use CurrentVersion: 0 with future keys. During a rolling deployment an older node can verify a row written with a preloaded newer version. Login rehash skips that row, while password change and reset continue hashing at the stored higher version. If the higher key was not preloaded, change/reset fail closed rather than writing the older current version. All three replacement paths use the same hash-and-version compare-and-swap, and the database independently rejects a lower new version. A lost race returns password_update_conflict for change/reset. Reset atomically claims its still-valid token, replaces the password, and deletes all sessions in one database transaction; change-password atomically combines its password replacement with its configured session revocation. Derivation is deterministic, so there is no password rotation-timestamp field; all instances only need the same version-to-secret mapping.
Never replace a secret in place under the same version. Add a new version instead. Losing a still-referenced key makes those accounts unverifiable; a compromised pepper may also require forced password resets because an attacker who obtained both the database and old pepper can continue offline guessing even while lazy upgrades proceed. SecurityConfig.SecretRotatedAt records application-secret rotation time for short-lived codes; it cannot recover password key material.
Expected errors
password_pepper: current version N has no configured key: the active version is missing, commonly because a map entry was omitted.password_pepper: key version N must be at least 32 bytes: an empty or short environment value was supplied. This fails duringNewConfig, andNew()revalidates after cloning; there is no runtime fallback to unpeppered hashing.password_pepper: key version 0 is reserved for unpeppered passwords: useCurrentVersion: 0, not a key at index zero, to keep new writes unpeppered.database contains password pepper versions with no configured key: startup found a stored version the configured keyring cannot verify.
Request protection
WithRateLimit and its narrower variants: Optional, on by default
Rate limiting is enabled by default with an in-memory store and a built-in table of per-route limits on sensitive endpoints (login, register, password reset, etc.). WithRateLimit replaces that whole table. The narrower options adjust one aspect of it without disturbing the rest: each one lazily starts from the same defaults if you have not called WithRateLimit first.
// Replace the whole table:
goauth.WithRateLimit(ratelimit.Config{
Enabled: true, // optional, default true
Default: ratelimit.Rate{Requests: 60, Window: time.Minute}, // optional, default 60/min
Routes: map[string]ratelimit.Rate{ // optional, default: a built-in table of sensitive routes
"POST /auth/login": {Requests: 10, Window: time.Minute},
},
Store: nil, // optional, default: a bounded in-memory store
DisabledRoutes: nil, // optional, default none
TrustedIPs: []string{"10.0.0.0/8"}, // optional, default none (required if IPAddressHeader is set)
IPv6Subnet: 64, // optional, default 64
IPAddressHeader: "CF-Connecting-IP", // optional, default "" (trusts RemoteAddr only)
})
// Or adjust one route without touching the rest:
goauth.WithRateLimitRoute("POST /auth/login", ratelimit.Rate{
Requests: 10, Window: time.Minute,
})| Option | Adjusts |
|---|---|
WithRateLimit(ratelimit.Config) | The entire configuration (see fields below). |
WithRateLimitEnabled(bool) | Just Enabled. |
WithRateLimitDefault(ratelimit.Rate) | Just the fallback rate for routes not listed in Routes. |
WithRateLimitRoute(pattern, ratelimit.Rate) | Adds or overrides one route's rate without replacing the table. |
WithRateLimitStore(ratelimit.Store) | Swaps the backing store (e.g. a Redis-backed one) without touching rates. |
WithTrustedIPs([]string) | Just TrustedIPs. |
WithIPv6Subnet(int) | Just IPv6Subnet. |
WithIPAddressHeader(string) | Just IPAddressHeader. |
ratelimit.Config
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
Enabled | bool | Optional | true | Disabling it logs a warning at startup: it is flagged as insecure for production. |
Default | ratelimit.Rate | Optional | 60 requests / minute | Fallback for any route not in Routes. |
Routes | map[string]ratelimit.Rate | Optional | a built-in table covering login, register, password reset, verification, invites, etc. | Keyed by "METHOD /path". A Rate{Requests: 0} entry disables limiting for that one route. |
Store | ratelimit.Store | Optional | a bounded in-memory store | Left nil, each successful New creates its own store when rate limiting is enabled; Auth.Close() stops it. NewConfig creates no store or background work. Supply one to share a distributed store (Redis, etc.) across instances. A supplied store is yours to close. |
DisabledRoutes | []string | Optional | none | ServeMux patterns exempt from rate counting; replaces DisabledPaths. |
TrustedIPs | []string | Optional | none | IPs/CIDRs. Required if IPAddressHeader is set. |
IPv6Subnet | int | Optional | 64 | Subnet prefix length used to bucket IPv6 clients. Must be 1–128. |
IPAddressHeader | string | Optional | "" (trusts RemoteAddr only) | The header is only honored from a peer in TrustedIPs; otherwise it is ignored, since an untrusted client could spoof it to pick its own rate-limit key. |
These two fields describe your proxy setup, not just your rate limiter
TrustedIPs and IPAddressHeader live under WithRateLimit for historical reasons, but three subsystems read them: the rate limiter (to key counters), the CSRF origin check (to recover the original scheme/host from X-Forwarded-*), and the address recorded on sessions and audit events.
If go-auth sits behind a reverse proxy and you leave these unset, every session and every audit row records the proxy's address. The session list and the audit log, whose whole job is to show an operator where a login came from, would show the same address for every user. Setting them once fixes all three. Rate limiting does not need to be Enabled for the other two to read them.
Expected errors (only reachable when Enabled: true)
rate_limit: route "..." requests must be positive, got N/... window must be positive, got ...: fromDefaultor any entry inRoutes. ARoutesentry may be exactly0(the per-route opt-out);Defaultmay not.rate_limit: ipv6_subnet must be between 1 and 128, got N.rate_limit: trusted_ips contains invalid IP/CIDR "...".rate_limit: ip_address_header is set but trusted_ips is empty - client-supplied header can be spoofed to bypass rate limiting: callWithTrustedIPsalongsideWithIPAddressHeader.
Route keys and exemptions use Go ServeMux patterns: POST /widgets/{id}, GET /files/{rest...}, and methodless paths for all methods. Invalid or ambiguous patterns fail configuration validation. Replace old * globs with named parameters and rename DisabledPaths to DisabledRoutes. See rate matching.
Organizations and permissions
WithOrganizations: Optional
Multi-tenant organizations. Disabled by default. When disabled, no organization routes are mounted at all.
goauth.WithOrganizations(goauth.OrganizationConfig{
Enable: true, // optional, default false
MaxOrgsPerUser: 10, // optional, default 0 (built-in cap of 100)
InviteTTL: 7 * 24 * time.Hour, // optional, default 7d
})| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
Enable | bool | Optional | false | |
MaxOrgsPerUser | int | Optional | 0 (built-in cap of 100) | Only checked when Enable is true. |
InviteTTL | time.Duration | Optional | 7d | Only checked when Enable is true. |
Expected errors (only reachable when Enable: true)
organizations.max_orgs_per_user must be between 0 and 100 (0 = default 100).organizations.invite_ttl must be positive: only reachable via an explicit negative value.
WithAppPermissions: Optional
goauth.WithAppPermissions(goauth.AppPermissionsConfig{
Enable: true,
EnableManagementHTTP: true,
DefaultRoleSlug: "user",
})| Field | Default | Meaning |
|---|---|---|
Enable | false | Use one SQL app role per account and current permission checks for library administrative actions. |
EnableManagementHTTP | false | Mount role/permission/catalog management endpoints; requires Enable:true. |
DefaultRoleSlug | "user" | Enabled non-admin role for new accounts; lowercase identifier of 1–80 characters. |
The default slug starts with a letter and then permits letters, digits and
hyphens; admin is forbidden. Startup validates its syntax without querying
role records. Account creation resolves the role in its transaction and fails
if authorization is uninitialized or that role is absent/disabled. There is no
startup initialization or automatic permission seeding. See the
app permission guide for bootstrap and delegation.
WithOrgPermissions: Optional
goauth.WithOrgPermissions(goauth.OrgPermissionsConfig{
Enable: true,
EnableManagementHTTP: true,
})Both flags default to false. Enable requires OrganizationConfig.Enable.
EnableManagementHTTP mounts initialization, catalog, permission, role, grant and
assignment endpoints and requires Enable. The Go capability and self-access
route are available with Enable alone. App permissions are configured separately.
Disabling enforcement preserves assignments; subsequent fixed-role changes still
reset affected assignments to their selected built-in roles before re-enabling.
Validation errors are org_permissions: requires organizations and
org_permissions: management HTTP requires Enable. Apply migration revision 2
before enabling; startup creates no authorization records. New organizations
initialize protected roles in their creation transaction. Existing organizations
require owner-authorized InitializeOrgPermissions before permission-protected
operations. See the org permission guide.
Operations and customization
WithLogger: Optional
goauth.WithLogger(slog.New(slog.NewJSONHandler(os.Stdout, nil)))| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
logger | *slog.Logger | Optional | slog.Default() | Used throughout: sessions, CSRF, rate limiting, audit, admin actions. |
No validation errors: any non-nil logger is accepted as-is.
WithAudit / WithAuditSink: Optional
goauth.WithAudit(goauth.AuditConfig{
Enabled: true, // optional, default false
FailureMode: audit.FailureOpen, // optional, default fail-open (or audit.FailureClosed)
RetentionDays: 0, // optional, default 0 (keep forever)
Workers: 3, // optional, default 3 — outbox dispatcher workers
BatchSize: 50, // optional, default 50 — outbox claim/delivery batch size
FlushInterval: 100 * time.Millisecond, // optional, default 100ms — dispatch poll interval
MaxAttempts: 10, // optional, default 10
ClaimLease: 10 * time.Minute, // optional, default 10m (floor 1m)
OutboxMaxAge: 7 * 24 * time.Hour, // optional, default 7d (negative disables)
DeadLetterTTL: 7 * 24 * time.Hour, // optional, default 7d (negative keeps forever)
OutboxMaxRows: 100000, // optional, default 100000 (negative = unbounded)
Sinks: []audit.EventSink{myKafkaSink}, // optional, default none
// EnqueueFailureMode: func(audit.Event) audit.FailureMode — optional, nil = fail-open everywhere
})
goauth.WithAuditSink(myOtherSink) // optional: adds one more sink at a time| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
Enabled | bool | Optional | false | |
FailureMode | audit.FailureMode | Optional | fail-open | Delivery fan-out across sinks within one dispatch batch. |
EnqueueFailureMode | func(audit.Event) audit.FailureMode | Optional | nil (fail-open) | Per-event: whether a failed durable write blocks the triggering operation (only where a transaction can roll back). |
RetentionDays | int | Optional | 0 (keep forever) | When positive, must exceed max(OutboxMaxAge, retryWindow + DeadLetterTTL) — rejected otherwise. |
ClaimLease | time.Duration | Optional | 10m | Must exceed every sink's declared worst-case batch delivery time; also rejected below 1m. |
Workers | int | Optional | 3 | Outbox dispatcher workers; only started when sinks are configured. |
BatchSize | int | Optional | 50 | |
FlushInterval | time.Duration | Optional | 100ms | |
MaxAttempts | int | Optional | 10 | |
OutboxMaxAge | time.Duration | Optional | 7d | |
DeadLetterTTL | time.Duration | Optional | 7d | |
OutboxMaxRows | int | Optional | 100000 | |
Sinks | []audit.EventSink | Optional | none | Additional destinations: Kafka, NATS, a webhook. Each external sink must also implement audit.BatchDeliveryTimeBounder. |
WithAuditSink appends one sink at a time and only takes effect when audit logging is enabled via WithAudit. Calling WithAudit more than once does not duplicate previously-added sinks. They are merged once, not accumulated per call.
Construction fails when ClaimLease is below 1m, does not exceed a sink's declared maximum batch time, or a sink cannot declare a finite bound. When RetentionDays is positive, both outbox clocks must be finite and retention must exceed max(OutboxMaxAge, retryWindow + DeadLetterTTL). Construction also fails when an external sink is configured before the audit_outbox migration exists; the application never starts in a state that promises delivery but cannot enqueue or drain it.
WithMaintenance: Optional, on by default
Background cleanup removes already-expired sessions and verification/reset tokens in bounded batches. A session must be past both its session and refresh expiry, plus the grace period, before it is eligible. This worker does not replace schema migrations or the separate audit and recovery queue cleanup.
goauth.WithMaintenance(goauth.MaintenanceConfig{
Disable: false,
Interval: time.Hour,
BatchSize: 1000,
Grace: 24 * time.Hour,
Timeout: 30 * time.Second,
// Logger: slog.Default(),
})| Field | Default | Meaning |
|---|---|---|
Disable | false | Stop background cleanup; Auth.RunMaintenance(ctx) remains available. |
Interval | 1h | Time between passes. |
BatchSize | 1000 | Maximum rows deleted per table per pass. |
Grace | 24h | Required time past expiry before deletion. |
Timeout | 30s | Deadline for each table's delete operation. |
Logger | slog.Default() | Cleanup diagnostics. |
Zero tuning values select these defaults; they do not disable a limit. Negative
values are rejected even when Disable is true, with maintenance interval must not be negative, maintenance batch_size must not be negative, maintenance grace must not be negative, or maintenance timeout must not be negative.
Auth.Close stops the worker.
Custom email templates
WithTemplates replaces what gets rendered; it has no effect on how it is delivered. That is still WithMailer/WithEmail. The interface is one method:
type TemplateProvider interface {
Render(data TemplateData) (TemplateResult, error)
}
type TemplateResult struct {
Subject string
HTML string
Text string
}Render is called once per email the library sends, with one of eight concrete TemplateData types. Switch on the concrete type (or on data.Template(), which returns an EmailTemplateType string) to decide which template to render:
TemplateData type | Template() | Fields | Sent for |
|---|---|---|---|
port.PasswordResetData | TemplatePasswordReset | AppName, ResetURL, ExpiresIn | Forgot password |
port.SetPasswordData | TemplateSetPassword | AppName, Code, ExpiresIn | Set password (OAuth-only accounts) |
port.VerificationData | TemplateVerification | AppName, Code, ExpiresIn | Email verification |
port.InviteData | TemplateInvite | AppName, InviteURL, ExpiresIn | Invite-only signup |
port.OrgInviteData | TemplateOrgInvite | AppName, OrgName, InviteURL, ExpiresIn | Organization invite |
port.DeleteAccountData | TemplateDeleteAccount | AppName, Code, ExpiresIn | Account deletion confirmation |
port.TwoFactorData | TemplateTwoFactor | AppName, Code, ExpiresIn | 2FA login code |
port.TwoFactorSuspiciousData | TemplateTwoFactorSuspicious | AppName, AttemptCount, ResetPasswordURL | Notify-only warning after repeated wrong 2FA codes on one account, never blocks login, see Security |
A minimal implementation:
type myTemplates struct{}
func (myTemplates) Render(data port.TemplateData) (port.TemplateResult, error) {
switch d := data.(type) {
case port.PasswordResetData:
return port.TemplateResult{
Subject: "Reset your " + d.AppName + " password",
HTML: "<a href=\"" + d.ResetURL + "\">Reset password</a> (expires in " + d.ExpiresIn.String() + ")",
Text: "Reset your password: " + d.ResetURL,
}, nil
case port.VerificationData:
return port.TemplateResult{
Subject: "Verify your email",
HTML: "<p>Your code: <strong>" + d.Code + "</strong></p>",
Text: "Your verification code: " + d.Code,
}, nil
// ... one case per type in the table above
default:
return port.TemplateResult{}, fmt.Errorf("unsupported template type: %T", data)
}
}
goauth.WithTemplates(myTemplates{})Four of the eight types (PasswordResetData, InviteData, OrgInviteData, TwoFactorSuspiciousData) carry a URL and additionally implement ValidateURLs(*port.URLValidator) error. The built-in template provider calls this before rendering to enforce https://-only links (subject to SecurityConfig.AllowHTTPURLs). A custom TemplateProvider bypasses that validation entirely: it is only wired up for the built-in provider. If you supply your own, you are responsible for whatever URL safety you want inside Render itself.
Overriding a single template
WithTemplates is all-or-nothing at the type level: implement Render and you own every template type, including the ones you do not actually want to change. To customize just one (say, password reset) without reimplementing the other seven, wrap the built-in provider (emailtemplate.New, which is exported) and delegate anything you do not override to it:
type myTemplates struct {
fallback *emailtemplate.Provider
}
func newMyTemplates(v *port.URLValidator) (*myTemplates, error) {
fallback, err := emailtemplate.New(v)
if err != nil {
return nil, err
}
return &myTemplates{fallback: fallback}, nil
}
func (t *myTemplates) Render(data port.TemplateData) (port.TemplateResult, error) {
if d, ok := data.(port.PasswordResetData); ok {
return port.TemplateResult{
Subject: "Reset your " + d.AppName + " password",
HTML: "<a href=\"" + d.ResetURL + "\">Reset password</a>",
Text: "Reset your password: " + d.ResetURL,
}, nil
}
return t.fallback.Render(data) // everything else uses the built-in template
}emailtemplate.New takes the same *port.URLValidator you would otherwise build yourself: construct one with &port.URLValidator{AllowHTTP: ...} matching your SecurityConfig.AllowHTTPURLs policy, since the fallback provider still enforces ValidateURLs even though your own override does not.
Next
- Introduction: what go-auth is and why it exists
- Installation: prerequisites and what you need before configuring
Redis integration
WithRedis(RedisConfig) enables session/token snapshots and shared rate-limit/2FA notification counters. Clients remain caller-owned; SQL remains authoritative. See Redis configuration for capability switches, deadlines, TTLs, startup checks and ownership.