Configuration
Every configuration option, field by field: required vs optional, defaults, and the exact validation errors each one can produce.
Configuration
All configuration goes through NewConfig(opts ...Option): apply every option you want, and it fills in defaults for anything you left unset, validates the result, and returns a value that New accepts. Options can be passed in any order, with one exception: a later WithRateLimit replaces the whole rate-limit table, discarding any earlier WithRateLimitRoute tweak. Set narrow options after the broad one.
NewConfig returns every problem it finds at once, joined into a single error. Not just the first one. Each section below lists the exact error text and what causes it.
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
},
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. |
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, and OTP-pepper keys from it. Source it from the environment; never commit it. Password peppering uses a separate opt-in secret. |
If you rotate the root secret, rotate the OTP-pepper timestamp with it:
goauth.WithSecret(os.Getenv("AUTH_SECRET")),
goauth.WithPepperRotatedAt(rotationTime), // UTC moment the new secret went liveWithPepperRotatedAt is optional and defaults to unset (zero): the rotation-stale branch stays dormant and verification is purely by HMAC. Set it, to the same UTC value on every instance, exactly when you rotate the secret. Pre-rotation low-entropy codes (2FA, verification, set-password, delete-account) then fail as expired ("resend") instead of invalid_code. Source it like the secret itself: one shared env var or secrets-manager entry, never per-instance files that can drift. Instances holding different timestamps disagree per-request about which codes are stale.
WithPepperRotatedAt governs OTP/code expiry only. Password peppering is independent, so rotating WithSecret does not invalidate password hashes. See Security → OTP pepper 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.
WithPasswordPepper: Optional
Password peppering is off by default. Enable it with independently managed secret material:
goauth.WithPasswordPepper(goauth.PasswordPepperConfig{
CurrentVersion: 1,
Keys: map[uint32]string{
1: os.Getenv("AUTH_PASSWORD_PEPPER_V1"),
},
})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. WithPepperRotatedAt is unrelated. It records OTP rotation time and 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.
WithSecurity: Required (AllowedOrigins)
CSRF origin allow-list, password policy, the double-submit CSRF cookie, and the http:// policy for email links.
goauth.WithSecurity(goauth.SecurityConfig{
AllowedOrigins: []string{"https://myapp.com"}, // required: at least one, never "*"
AllowMissingCSRFHeaders: false, // optional, default false
DisableCSRFToken: false, // optional, default false (the token layer is ON)
CSRFToken: &middleware.CSRFTokenConfig{ // optional: overrides only; a config is created for you
TokenLength: 32, // optional, default 32
CookieName: "_csrf", // optional, default "_csrf"
HeaderName: "X-CSRF-Token", // optional, default "X-CSRF-Token"
CookiePath: "/", // optional, default "/"
CookieSameSite: http.SameSiteLaxMode, // optional, default Lax
CookieDomain: "", // optional, defaults to CookieConfig.Domain
ExposeCSRFTokenInBody: false, // optional, default false; see "Where is your frontend?"
// CookieSecure and Secret are filled in automatically. Do not set them.
},
PasswordPolicy: domain.PasswordPolicy{ // optional, default {MinLength: 8, RequireDigit: true}
MinLength: 12,
RequireUppercase: true,
RequireDigit: true,
RequireSpecial: false,
},
AllowHTTPURLs: goauth.RequireHTTPSEmailLinks(), // optional, default derived from Environment
})| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
AllowedOrigins | []string | Required | none | At least one entry. "*" is rejected outright. |
AllowMissingCSRFHeaders | bool | Optional | false | Allow requests with no Origin/Referer header at all (needed for some native/mobile clients). |
DisableCSRFToken | bool | Optional | false (layer is on) | Turns off the double-submit cookie layer entirely. Origin/Referer checking still applies and cannot be disabled. |
CSRFToken | *middleware.CSRFTokenConfig | Optional | auto-created | Overrides only. Leaving it nil does not disable anything (see below). |
PasswordPolicy | domain.PasswordPolicy | Optional | MinLength: 8, RequireDigit: true | Setting any one field means you specify the whole policy (see note below). |
AllowHTTPURLs | *bool | Optional | derived (true in EnvironmentDev, false otherwise) | Governs http:// links in rendered email templates only, not transport. Override with goauth.AllowPlaintextEmailLinks() or goauth.RequireHTTPSEmailLinks(). |
PepperRotatedAt | time.Time | Optional | zero (branch dormant) | Moment the current secret went live (UTC). Prefer the surgical WithPepperRotatedAt option over restating this struct at rotation time. Must be identical on all instances (see above). |
PasswordPolicy fields (MinLength int, RequireUppercase bool, RequireDigit bool, RequireSpecial bool) are all individually optional, but the struct as a whole replaces the default. If you set RequireUppercase: true without repeating MinLength or RequireDigit, those two are no longer applied.
Validate also rejects any password over 72 bytes, regardless of MinLength. That is bcrypt's real input limit (measured in bytes, not characters, so a multi-byte passphrase hits it sooner than an all-ASCII one of the same length), and the check runs before hashing so a too-long password gets a clear 400 weak_password instead of a generic 500. Do not set MinLength above 72.
The double-submit token layer is on in every deployment. WithSecret is
mandatory at 32+ bytes, and New creates a CSRFTokenConfig with the defaults
below whenever you leave the field nil: passing the struct overrides those
defaults, it does not switch the layer on.
To actually turn it off, set DisableCSRFToken: true. That is intended for
deployments with no browser clients (a CLI or a server-to-server API), where
CSRF is not in the threat model and the token round-trip buys nothing. Every
state-changing route otherwise requires the client to GET /auth/csrf-token
first and echo the cookie value back in the header.
One combination is rejected
DisableCSRFToken: true together with AllowMissingCSRFHeaders: true is a
configuration error. Each is defensible alone, but together a cross-site
request carrying neither an Origin/Referer header nor a token would pass,
leaving no CSRF defense at all. Keep one of the two layers.
Expected errors
allowed_origins must include at least one origin:AllowedOriginswas left empty.allowed_origins must not contain "*" — this disables CSRF protection; list specific origins instead:AllowedOriginscontained"*".security: DisableCSRFToken and AllowMissingCSRFHeaders cannot both be set — a cross-site request with no Origin/Referer and no token would pass; keep one of the two layers: both CSRF layers were turned off at once (see callout above).
CSRFToken (middleware.CSRFTokenConfig): every field is optional and takes the default shown when omitted.
| Field | Default |
|---|---|
TokenLength | 32 bytes |
CookieName | _csrf |
HeaderName | X-CSRF-Token |
CookiePath | / |
CookieSameSite | Lax |
CookieDomain | inherited from CookieConfig.Domain |
ExposeCSRFTokenInBody | false |
CookieSecure | derived from the resolved session cookie Secure value |
Secret | filled automatically from WithSecret |
Where is your frontend?
The double-submit check needs the browser to send _csrf and let JavaScript read it so the value can be echoed in X-CSRF-Token. The deployment topology determines which configuration is required.
| Topology | Sends? | Readable? | What to set |
|---|---|---|---|
| Same origin, the frontend proxies the API (both reference apps do this) | yes | yes | nothing |
Sibling subdomains, app.example.com and api.example.com | yes, they are the same site | not by default | CookieDomain: ".example.com" here, and leave CookieConfig.Domain alone |
Different registrable domains, panel.acme.com and api.example.com | only with SameSite=None | never | CookieSameSite: http.SameSiteNoneMode, a secure cookie, and ExposeCSRFTokenInBody: true |
Two things that trip people up, both covered in full in the Deployment guide:
- Subdomains are the same site, not a cross site deployment.
SameSiteis computed from the registrable domain and ignores the subdomain and the port, so the defaultLaxcookie is sent normally there. Only readability is missing, andCookieDomainis what fixes it. A leading dot is optional:net/httpstrips it on write, RFC 6265 ignores it on read, and anyDomainattribute covers subdomains. - Only the CSRF cookie needs a
Domain. WideningCookieConfig.Domainas well shares your session across every subdomain, and is not required for a sibling subdomain frontend to authenticate. Do it only when you want one login to cover both.
ExposeCSRFTokenInBody makes GET /auth/csrf-token answer 200 {"token": "..."} (with Cache-Control: no-store) instead of a bare 204. Turn it on only for the third row: across two registrable domains no cookie scope can make _csrf readable by the frontend, so the response body is the only channel left.
It does not weaken the check. The cookie is still set and still compared server side, and reading that body requires the caller's origin to be in AllowedOrigins (the same gate OriginCheck applies to every mutation). The exposure that matters in a cross site deployment comes from SameSite=None, which removes the browser's own barrier against the cookie riding along on a cross site request. This flag accompanies that decision rather than causing it, and it stays off by default because most deployments read the cookie directly.
Startup warnings (logged, not fatal; a native client keeps its own cookie jar and is not subject to SameSite, so either setting alone is legitimate there):
-
cookie SameSite=None without CSRFTokenConfig.ExposeCSRFTokenInBody: a cross-site browser frontend receives the session cookie but cannot read the CSRF token, so reads work and every write403s. -
CSRFTokenConfig.ExposeCSRFTokenInBody without cookie SameSite=None: the reverse. The frontend can read the token but is never sent the session cookie, so everything arrives unauthenticated. -
cookie: same_site=None requires a secure cookie - browsers reject SameSite=None without Secure; use an https:// BaseURL or goauth.SecureAlways(): the pair yields no session rather than a weaker one, so this is caught in config instead of surfacing as "login succeeds but never sticks".
WithPasswordHasher / WithBcryptCost: Optional
Password hashing defaults to bcrypt at cost 12. WithPasswordHasher replaces bcrypt for new writes and rehash-on-login; WithBcryptCost only changes bcrypt's work factor. go-auth includes an Argon2id implementation in hasher/argon2id. Argon2id is opt-in and does not change the default.
cfg, err := goauth.NewConfig(
// ...required options...
goauth.WithPasswordHasher(argon2id.New(argon2id.DefaultOptions())),
)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.
| Option | Type | Default | Notes |
|---|---|---|---|
WithPasswordHasher | port.Hasher | bcrypt, cost 12 | Replaces the current hasher 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. |
WithBcryptCost | int | 12 | Keeps bcrypt and changes only its cost. Existing bcrypt rows remain verifiable because their stored hashes carry their own cost; rows at another cost are upgraded after a successful login. |
WithPasswordPepper | PasswordPepperConfig | disabled (CurrentVersion: 0) | Adds the optional versioned HMAC layer before the selected KDF. See WithPasswordPepper for rollout and rotation. |
If both options are present, WithPasswordHasher is the more specific choice and wins regardless of option order. WithBcryptCost does not modify a custom hasher.
Expected error
bcrypt_cost N exceeds bcrypt.MaxCost (31):WithBcryptCostwas 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.
WithTwoFactor: Optional
Email two-factor settings are separate from CSRF and password policy.
goauth.WithTwoFactor(goauth.TwoFactorConfig{
RequireEmail2FA: false, // optional, default false
DefaultEnabled: false, // optional, default false
CodeTTL: 5 * time.Minute, // optional, default 5m
DisableChallengeBinding: false, // optional, default false
ChallengeCookieName: "_2fa_challenge", // optional, default "_2fa_challenge"
DisableAdminTwoFactor: false, // optional, default false
})| Field | Type | Default | Notes |
|---|---|---|---|
RequireEmail2FA | bool | false | Makes email 2FA mandatory for password login, registration, and invite registration. |
DefaultEnabled | bool | false | Seeds User.TwoFactorEnabled at registration; users may still opt out. |
CodeTTL | time.Duration | 5m | Lifetime of a 2FA login code. |
DisableChallengeBinding | bool | false | Turns off the browser binding cookie for non-browser consumers. |
ChallengeCookieName | string | _2fa_challenge | Overrides the binding cookie name. |
DisableAdminTwoFactor | bool | false | Turns off admin login's unconditional second factor. |
Expected errors
two_factor: code_ttl must be positive: only reachable via an explicit negative value; an omitted one defaults to5m.two_factor: RequireEmail2FA has no effect when EnableEmailPassword is disabled — every gated path is a password path: mandatory 2FA was turned on with no password registration to gate.
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.
WithCookie: Optional
goauth.WithCookie(goauth.CookieConfig{
Name: "goauth_session", // optional, default "goauth_session"
RefreshName: "goauth_refresh", // optional, default "goauth_refresh"
Domain: "", // optional, default "" (host-only cookie)
Path: "/", // optional, default "/"
SameSite: http.SameSiteLaxMode, // optional, default Lax
Secure: goauth.SecureAlways(), // optional, default derived from Environment/BaseURL
})| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
Name | string | Optional | goauth_session | |
RefreshName | string | Optional | goauth_refresh | |
Domain | string | Optional | "" (host-only cookie) | Widen it (".example.com") only to share one login across subdomains. A frontend on a sibling subdomain does not need this to authenticate: the request goes to the API's own host either way. Scope only the CSRF cookie instead, with CSRFTokenConfig.CookieDomain. See Deployment. |
Path | string | Optional | / | |
SameSite | http.SameSite | Optional | Lax | Keep Lax unless a browser frontend is on a different registrable domain from the API. Subdomains of one domain are the same site and work on Lax. None requires Secure (see the error below) and pairs with CSRFTokenConfig.ExposeCSRFTokenInBody. See Deployment. |
Secure | *bool | Optional | derived (true unless Environment is dev and BaseURL is http://) | Tri-state: nil derives it, so the same code is correct in development and production. Override with goauth.SecureAlways() or goauth.SecureNever() (local http:// development only). |
Expected errors
cookie name cannot be empty: not reachable throughNewConfig(defaults fill an emptyNamebefore validation runs).cookie: same_site=None requires a secure cookie - browsers reject SameSite=None without Secure; use an https:// BaseURL or goauth.SecureAlways(): every current browser discards aSameSite=Nonecookie that is not alsoSecure, so the pairing produces no session rather than a weaker one, from the first request, with nothing in the server logs to explain it. This is checked against the resolvedSecurevalue, so anhttps://BaseURLsatisfies it without settingSecureexplicitly.
Every other field is defaulted before the config is checked and cannot fail validation.
These settings depend on where your frontend is hosted
Domain and SameSite are the two fields you touch when the frontend is not same-origin with the API, and they pair with CSRFTokenConfig.CookieDomain and ExposeCSRFTokenInBody above. The Deployment guide walks the three topologies and says which fields each one needs, along with what each costs.
WithRegistration: Optional, replaces wholesale
Which signup methods are available. Login is always available regardless of these flags: they only govern registration.
goauth.WithRegistration(goauth.RegistrationConfig{
EnableEmailPassword: true, // optional, default true
EnableOAuth: true, // optional, default true
EnableInvite: false, // optional, default false
AllowPublic: true, // optional, default true
RequireEmailVerification: false, // optional, default false
InviteTTL: 7 * 24 * time.Hour, // optional, default 7d
VerificationCodeTTL: 15 * time.Minute, // optional, default 15m
VerificationResendInterval: 60 * time.Second, // optional, default 60s (negative = no minimum)
})| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
EnableEmailPassword | bool | Optional | true | |
EnableOAuth | bool | Optional | true | Allows OAuth to create a new account, not just link one. |
EnableInvite | bool | Optional | false | A mailer is required regardless (see below). |
AllowPublic | bool | Optional | true | Public (non-invite) registration is reachable at all. |
RequireEmailVerification | bool | Optional | false | A mailer is required regardless (see below). |
InviteTTL | time.Duration | Optional | 7d | |
VerificationCodeTTL | time.Duration | Optional | 15m | |
VerificationResendInterval | time.Duration | Optional | 60s | Minimum time between verification email resends (the mail-bombing guard). Throttled resends return 200 with codeSent: false rather than an error. Set a negative value to opt out back to no minimum. |
A bool has no "unset" state: an omitted false looks identical to a deliberate one. Because of that, calling WithRegistration at all replaces every flag wholesale rather than merging into the defaults. List every method you want enabled, since anything you do not mention becomes false. The two TTL fields and VerificationResendInterval are the exception: they keep their defaults when left at zero even if you do call WithRegistration.
Expected errors
email: Mailer or Email config required — enabled features need it: <reasons>: the<reasons>list names exactly which ofEnableInvite,RequireEmailVerification,RequireEmail2FA,DefaultEnabled, or AdminLogin two-factor is still requiring one. CallWithMailer/WithEmail, or turn off every reason listed (TwoFactorConfig.DisableAdminTwoFactorfor the last one). SeeWithMailer/WithEmail/WithTemplatesbelow for theEnvironmentDevexception.registration: invite_ttl must be positive: only reachable via an explicit negative value; an omitted one defaults to7d.registration: verification_code_ttl must be positive: same, defaults to15m.registration: RequireEmailVerification has no effect when both EnableEmailPassword and EnableOAuth are disabled.registration: AllowPublic is true but no registration method is enabled:AllowPublicis true butEnableEmailPassword,EnableOAuth, andEnableInviteare all false.
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.
WithMailer / WithEmail / WithTemplates: required unless every email feature is off
A mailer is required whenever some configured feature actually sends email: EnableInvite, RequireEmailVerification, TwoFactorConfig.RequireEmail2FA, TwoFactorConfig.DefaultEnabled, or POST /auth/admin/login's two-factor challenge (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 | Turn off POST /auth/admin/login's unconditional two-factor challenge | 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. |
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.
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.
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
DisabledPaths: 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, one is created for you and closed by Auth.Close(). Supply one to swap in a distributed store (Redis, etc.) across multiple instances. A supplied store is yours to close. |
DisabledPaths | []string | Optional | none | |
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.
WithProvider: Optional
Registers an OAuth provider. Call it once per provider; OAuth routes are mounted only when at least one is registered.
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.
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.AuditFailureOpen, // optional, default fail-open (or audit.AuditFailureClosed)
RetentionDays: 0, // optional, default 0 (keep forever)
QueueSize: 1000, // optional, default 1000
Workers: 3, // optional, default 3
BatchSize: 50, // optional, default 50
FlushInterval: 100 * time.Millisecond, // optional, default 100ms
Sinks: []audit.EventSink{myKafkaSink}, // optional, default none
})
goauth.WithAuditSink(myOtherSink) // optional: adds one more sink at a time| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
Enabled | bool | Optional | false | |
FailureMode | audit.AuditFailureMode | Optional | fail-open | |
RetentionDays | int | Optional | 0 (keep forever) | |
QueueSize | int | Optional | 1000 | |
Workers | int | Optional | 3 | |
BatchSize | int | Optional | 50 | |
FlushInterval | time.Duration | Optional | 100ms | |
Sinks | []audit.EventSink | Optional | none | Additional destinations: Kafka, NATS, a webhook. |
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.
No validation errors: all fields are defaulted rather than rejected.
Next
- Introduction: what go-auth is and why it exists
- Installation: prerequisites and what you need before configuring