Audit status. egauth's security review to date is an AI-driven audit only; it has not had an independent third-party human security audit, and that risk is accepted for v1.0 — pin a reviewed commit, commission your own audit, or wait if that trade-off is unacceptable. "AI-audited" is not a synonym for "audited". See AUDIT.md for the full review scope (what was reviewed and how, what was not, the accepted trade-offs) and the cautious-user escape hatch.
This document describes how egauth handles sensitive values (passwords, opaque
tokens, hashes) and what the consumer of the library is responsible for.
The per-module summary — what each package guarantees and what the consumer must do — lives in docs/security-guarantees.md. This document remains the detailed model behind those statements.
-
Hashing at rest. Opaque tokens (refresh tokens, API keys, session tokens) are never persisted in clear text. Only their SHA-256 hash is stored (
tokens.HashToken), so a database leak does not expose usable credentials. Lookups are performed on the hash, which is what makes a plain index/equality lookup safe for high-entropy tokens. The library enforces a minimum token byte length (jwt.MinTokenLength = 16) forRefreshLengthandAPIKeyLength:Config.Validatereturns an error andNewpanics if either is set to a positive value below the minimum, preventing low-entropy tokens from being issued accidentally. -
Constant-time password comparison (by construction). Password verification compares the derived key with
crypto/subtle.ConstantTimeCompare(passwords/argon2), so a wrong password cannot be recovered byte-by-byte through timing. The guarantee is structural:Comparealways reaches the constant-time comparison for any well-formed stored hash and never branches on the byte-wise outcome of the secret comparison. (A malformed stored hash is rejected before the KDF — but that depends only on the shape of the untrusted stored hash, not on the candidate password, so it leaks nothing about the password.) This is not provable by a boolean unit test; the supporting evidence is a pair of benchmarks (BenchmarkCompare_CorrectPasswordvsBenchmarkCompare_WrongPasswordinpasswords/argon2) whose measured per-op timings land within benchmark noise of each other. -
Constant-time authentication paths (by construction). The password authentication path applies an equivalent hashing cost (a full Argon2id pass via the decoy-hash path) even when the user, identity, or password hash is absent, or the provider is non-password, so account existence cannot be inferred from response timing (user-enumeration defence). Again the guarantee is structural — every enumeration-safe branch in
AuthenticatecallsdecoyHash. The supporting evidence isBenchmarkAuthenticate_ValidUser_WrongPassword(realCompare) vsBenchmarkAuthenticate_UnknownUser/BenchmarkAuthenticate_NonPasswordProvider(decoy hash) inidentity, whose measured deltas are within benchmark noise.Running the timing-evidence benchmarks. These are evidence to inspect manually, not CI pass/fail gates (a wall-clock threshold on a shared runner is too flaky to gate a build):
go test -run=^$ -bench=BenchmarkCompare -benchmem ./passwords/argon2 go test -run=^$ -bench=BenchmarkAuthenticate -benchmem ./identity # For a noise-aware comparison across a change, capture multiple runs and use benchstat: go test -run=^$ -bench=BenchmarkAuthenticate -benchmem -count=10 ./identity | tee old.txt # ...make the change... go test -run=^$ -bench=BenchmarkAuthenticate -benchmem -count=10 ./identity | tee new.txt benchstat old.txt new.txt # go install golang.org/x/perf/cmd/benchstat@latest
A benchstat-significant gap between the correct/valid and wrong/unknown variants would signal a regression in the constant-time guarantee (e.g. a path that skips the decoy or short-circuits the comparison) and should be investigated. Note the benchmark fixture disables lockout (
WithNoLockout) so the valid-user path keeps exercisingCompareevery iteration instead of short-circuiting onErrAccountLocked; lockout remains on by default in production. -
Brute-force lockout (identity). After
DefaultLockThreshold(5) consecutive password failures the identity is locked forDefaultLockDuration(15 min). Lockout is on by default and hardened against misconfiguration:identity.WithLockout(0, 0)does NOT disable it — a non-positive argument falls back to the safe default, matching the convention ofmfa.WithMaxAttempts. To explicitly opt out (e.g. when an external WAF or rate-limiter enforces the budget), useidentity.WithNoLockout(), which makes the intent auditable and greppable. -
Single-use refresh-token rotation with theft detection. Refresh tokens are single-use and chained by
FamilyID. Each rotation atomically consumes the old token and mints a new one in the same family; the access-token lifetime is always issuer-controlled on rotation, and the family's tenant is immutable across rotations. Replaying a consumed token that is still within its validity revokes the entire family (forcing re-authentication), and a revocation that fails is surfaced rather than silently swallowed. (Once a token has expired it can no longer be rotated and may have been reaped by theDeleteExpiredGC, so a post-expiry replay reports not-found instead of revoking the family — the theft tripwire spans a token's validity, by which point an expired token grants no access regardless.) To avoid logging users out on ordinary request concurrency (parallel tabs, prefetch, concurrent sub-resource loads racing the same cookie), a replay withinReuseGracePeriod(default 10s) of consumption — and the lost-race case where two requests rotate the same not-yet-consumed token in parallel — is treated as benign and rejected without revoking the family. These benign cases surface the distincttokens.ErrRefreshConcurrentsentinel (which wrapsErrRefreshTokenReusedfor compatibility), so the cookie-clearing callers (RequireAuthauto-refresh andRefreshHandler) clear only the stale access cookie and leave the refresh cookie intact: the winning request already minted a fresh, valid refresh cookie for this client, and clearing it would wipe that and force a full re-login — the very lockout the grace window exists to prevent. After-grace reuse, expiry and not-found still clear all cookies. Set a negativeReuseGracePeriodfor strict mode where any replay revokes. -
Single-use verification tokens (selector/verifier). Password-reset and email-verification tokens follow a selector/verifier scheme: a 128-bit random
selectorindexes the row, and only the SHA-256 of the secretverifierhalf is stored. Consumption compares the verifier in constant time, is atomic and single-use (a guarded delete), reports a verifier mismatch identically to an unknown token, and mints/consumes only for a live, same-tenant user (enforced identically by the memory and Postgres stores). A weak new password or a hashing failure is rejected before the token is consumed, so it is never burned for nothing. -
OAuth is CSRF- and phishing-hardened. The authorization-code flow uses PKCE (S256) and a single-use
statevalue bound in anHttpOnly/Secure/SameSite=Laxcookie (compared in constant time on callback). By default the callback refuses to provision or sign in from a provider email the provider reports as unverified (WithAllowUnverifiedEmailopts out), and it never auto-links an external identity onto a pre-existing account that merely shares the email — both are account-squatting / takeover defences. The token exchange runs server-side with the client secret; the provider access token never leaves the exchange. Both the token POST (which carriesclient_secret) and the userinfo GET (which carries the access token) useoauth.SafeHTTPClientby default: its dial-time guard refuses loopback/link-local/RFC1918/ RFC6598/multicast targets (DNS-rebinding safe) and 3xx responses are never followed, so a hostile or tenant-controlled issuer cannot point those fetches at an internal address. Injectingoauth.WithHTTPClient(or the dev-onlyoauth.WithInsecureURLs) replaces that client with an unguarded one. -
NIST-aligned passphrases.
passwords/policy.PassphrasePolicyenforces length (counted in Unicode code points) with NO composition rules and screens secrets against a denylist plus an optional pluggablepasswords.BreachChecker(e.g. a HIBP k-anonymity client — egauth ships the interface only, never the network call). -
TOTP & recovery codes. The
mfamodule implements RFC 6238 TOTP (authenticator apps only, no SMS) with a ±skew window and replay protection via a monotonic last-used time-step (a code, including the enrolling one, cannot be reused). Recovery codes are single-use and stored only as SHA-256 hashes.NewServicepanics at construction ifWithDigitsis called with a value outside the RFC 6238 range 6–8 — values below 6 produce a trivially guessable code space and values above 8 cause uint32 truncation in the HOTP truncation step, neither of which will be accepted by any compliant authenticator app. Caveat: a TOTP shared secret must be stored in recoverable form (the server recomputes codes from it), so — unlike passwords/opaque tokens — it is NOT hashed. Per the PRD's "no at-rest encryption in v1" non-objective, themfastore persists the secret in clear; deployments that need defense against a database leak should encrypt thesecretcolumn at the storage/DB layer (envelope encryption). Failed-attempt lockout is time-bound. OnceFailedAttemptsexceedsMaxAttempts(default 5) the factor is locked andConfirmTOTP,VerifyTOTP, andVerifyRecoveryCodeall returnErrTooManyAttempts. WhenConfirmTOTPexhausts the budget the pending enrollment is deleted so an attacker cannot continue guessing; the user must restart fromEnrollTOTP. The lockout automatically resets afterLockoutDuration(default 15 min, measured from the last failed attempt), giving legitimate users a self-service recovery path without operator action. Operators can also unblock a user immediately viaService.UnlockMFA(ctx, tenantID, userID), which wrapsStore.ResetTOTPAttempts. The window is configurable viamfa.WithLockoutDuration(d); passing0makes the lockout permanent untilUnlockMFAis called or the factor is disabled. -
Passkeys (WebAuthn). The
passkeymodule wraps go-webauthn. Credentials are scoped to the configured Relying Party ID; the ceremony challenge and user-verification requirement (SessionData) are carried between Begin and Finish in a short-lived, HMAC-signedHttpOnly/Securecookie so the client cannot tamper with the challenge or downgrade user verification; the cookie is single-use and the ceremony has a server-enforced expiry. A regressed signature counter (possible cloned authenticator) is rejected (ErrCredentialCloned). The non-ceremonypasskey.RenameCredentialHandlermutation applies the library-wide strict same-origin CSRF check — on by default, withpasskey.WithTrustedOrigins/passkey.WithInsecureNoOriginCheck— and requiresContent-Type: application/json(415 otherwise) as defense in depth; see the CSRF section below. The module is secure by default:passkey.NewServicefails fast on a misconfigured passwordless/step-up setup rather than degrading silently (mirroringjwt.New). See the hardening checklist below.Passwordless / step-up hardening checklist — required configuration for a secure deployment (each item is enforced at construction unless noted):
Config.CookieKey(required). A stable, random secret of at leastpasskey.MinCookieKeyLength(32) bytes used to HMAC-authenticate the ceremony cookie.NewServicereturnsErrCookieKeyMissingif it is unset or too short — the key is validated at construction, not on the first ceremony, so a misconfiguration fails at startup. (A per-handlerpasskey.WithCookieKeyoverride still exists for the rare case of a distinct key, and the handlers also fail closed defensively if that override clears the key.)Config.ChallengeStore(required). Provides single-use, server-side replay protection (SEC-05): the challenge is recorded on Begin and atomically consumed on Finish, so a captured raw Finish request cannot be replayed within the cookie TTL.NewServicereturnsErrChallengeStoreMissingunless a store is supplied or the explicit opt-outConfig.InsecureNoChallengeStoreis set (cookie-only protection — do not use for passwordless). Thepasskey/memoryandpasskey/pgxsubpackages provide implementations.Config.UserVerification(defaults to required). The zero value is nowprotocol.VerificationRequired: an assertion whose User Verified (UV) flag is unset is rejected at Finish across register, login and discoverable login. Leave it at the default for passwordless/step-up; set it explicitly toVerificationPreferred/VerificationDiscouragedonly for a flow where another factor already authenticated the user.- Serve over HTTPS so the
Secureceremony cookie is sent;passkey.WithInsecureCookiesis for local HTTP development only. - Rate-limit ceremony attempts in front of the handlers (egauth does not throttle them — see the next bullet).
-
MFA verification is not rate-limited by egauth. Per the non-objectives, throttling TOTP / recovery-code / passkey attempts is the consumer's responsibility; egauth exposes the errors and propagates
context.Contextso an external limiter can be attached in front of the handlers. -
Step-up / AAL enforcement. Tokens carry an
AMRclaim (RFC 8176) recording the factors used to obtain them. The model has two halves and both ship in egauth:- Enforcement.
tokens.WithRequiredAMR(...)gates a route on those factors (e.g. requireAMRMFA), returning 403 for an authenticated-but-under-assured subject. It fails closed: a token that does not carry the required AMR value never passes, so a password-only session can never satisfyWithRequiredAMR(AMRMFA). - Production.
identity.WithMFAGate(mfaSvc)makesLoginHandlercheckIsEnrolledafter a correct password; an enrolled user receives a short-lived interim access token (AMR=[AMRPassword], default 5 min, configurable viaWithInterimTokenTTL) and no refresh cookie, so the pre-MFA state is not an indefinitely renewable session. The second factor is then driven bymfa.StepUpHandler, which on a correct TOTP re-issues the full access+refresh pair withAMR=[AMRPassword, AMROTP, AMRMFA]and sets both cookies, replacing the interim access cookie. Users with no enrolled factor are unaffected and receive the full pair.
Without
WithMFAGate/StepUpHandler, AMR production is entirely consumer-implemented: the application'sClaimsBuilder/ClaimsProvidermust stamp the AMR values itself when issuing the pair after a second factor, and a plainLoginHandlerissues a full refreshable pair on the password alone. On refresh the AMR is re-evaluated by theClaimsProvider, not frozen at login. To make that re-evaluation per-session rather than per-user,Rotateattaches atokens.RotationContext(the rotation family ID and the family's preservedauth_time) to the context passed toClaimsProvider.ClaimsForUser; recover it withtokens.RotationContextFromContext. This lets a provider keyed by family ID preserve (or deliberately downgrade) the assurance the family originally proved, instead of being forced to either silently decay a legitimately MFA-elevated session after one access-token TTL or blanket-elevate every session of an MFA-enrolled user — the latter being a step-up bypass where a password-only family would gainAMRMFAon its first silent refresh. - Enforcement.
-
Forced-password-change for temporary credentials (soft gate). egauth forces a password change only for admin-provisioned credentials —
identity.AdminCreateUser(admin-created account) andidentity.SetTemporaryPassword(admin-issued one-time password) set theMustChangePasswordflag on the identity explicitly, so the user must choose their own password before the app is usable. egauth deliberately does not offer periodic, age-based password rotation: forcing expiry on a fixed interval is discouraged by NIST SP 800-63B (it drives weaker, predictably-incremented passwords) and is intentionally not implemented.The soft-gate contract: a flagged credential still authenticates — the user is never locked out. At next login the handler detects the flag (via
identity.PasswordChangeRequired) and issues a full, renewable token pair whose access token carriestokens.Claims.MustChangePassword=true. Crucially the flag is recorded on the refresh-token family, andRotatereplays it verbatim onto every silent refresh (overriding whatever theClaimsProviderreturns), so the renewed token is flagged if and only if the family it descends from was flagged. A user therefore cannot escape the gate by waiting for the access token to expire and refreshing — the carry-forward is enforced in the token layer, not the handler. The flag clears only when a fresh family is minted (a new login after the password is changed). To force a change on a user's existing active sessions, an administrator revokes their families —SetTemporaryPassworddoes this via the registeredAccountErasers.The
tokens.WithPasswordChangeGateAuthOptionenforces the gate generically in theRequireAuthmiddleware: after successful token verification, ifClaims.MustChangePasswordis true the wrapped handler is bypassed and the request is redirected (303) to the configured reset URL (or403 password_change_requiredif none). The change-password and logout routes should be excluded from this middleware.The flag is preserved structurally on every interactive login path by the unified session issuance pipeline (
issuance.Pipeline.Issue). Password login, registration, magic link, the native OAuth callback, theauthflowengine's minter and the MFA step-up handler all terminate in that one function, which re-loads the account's authoritative state and computesClaims.MustChangePasswordas the OR of the caller's signal and the authoritative flag — a caller (or a flow engine without a checker) can add the flag but never clear it. A lookup error aborts issuance without minting anything. This is whyoauth.IdentityLinkerrequiresPasswordChangeRequiredalongsideLinkOrCreateIdentity. On theWithAuthFlowpath the handler resolvesPasswordChangeRequiredand supplies it to the engine, which ORs it with its own optionalauthflow.WithPasswordPolicyCheckerresult before the engine's minter enters the same pipeline. The passkey login callback is application-owned and remains free to mint its own session, butpasskey.WithLoginSuccessWithTenantsurfaces the resolved tenant so the callback can route issuance through the same pipeline; wireConfig.AccountGateso the passkey ceremony itself still refuses suspended or deleted accounts.egauth never proactively re-queries the credential's state on refresh and never auto-revokes sessions to force a change: the flag is set at login and carried forward, and forcing a change on live sessions is an explicit administrative action (family revocation). A flagged-but-not-yet- logged-in user is unaffected until their next login attempt.
On a successful
ChangePassword/ResetPassword,UpdateIdentityPasswordatomically stampsPasswordChangedAtand clearsMustChangePassword;ChangePasswordWithReissueHandlerthen re-issues a full access+refresh pair so the user is immediately authenticated and the change-password redirect loop stops.PasswordChangedAt(added by migration008_add_password_change_columns.sql) is informational audit metadata — "when the password hash was last set" — stamped on every password write. It does not drive any forced-change decision (there is no age-based policy); a zero value on a legacy row is simply an unknown last-changed time and is harmless. -
API keys: PAT and service tokens. egauth issues two kinds of long-lived key via
IssueAPIKey. A PAT (Personal Access Token) acts on behalf of a human:Actor.Kind == egauth.PAT,IsHuman()returnstrue, andActor.UserIDis the owning user's UUID. A Service token is a machine identity decoupled from any human:Actor.Kind == egauth.Service,IsMachine()returnstrue, and the token's subject is the key's own ID (recorded inActor.KeyID); the human who created it is tracked separately in the key'sCreatedByfield and emitted on theapi_key.createdaudit event.A key's authority is only the scopes you pass at issuance.
IssueAPIKeydoes not copy, inherit, or mirror the creating user's live roles. PassingScopes: ["repo:read"]at issuance constrains the key to that capability regardless of the issuing user's full privilege set. This is the safe default — a leaked PAT is bounded to the scopes it was issued with. If a broader scope is appropriate, pass it explicitly; egauth never widens it silently.Both key types are stored as SHA-256 hashes only — the clear-text token is returned exactly once at issuance and is never persisted. Audit events for the key lifecycle never carry the token, its hash, or any raw user input; they carry only short machine
Reasoncodes and safe metadata:api_key.created— fired on issuance;Attrscarry"key_type"(pat/service) and"created_by".api_key.auth.succeeded— fired on a successful verify;Attrscarry"key_type", and optionally"ip"/"user_agent"when aevent.RequestContextis threaded in by the handler.api_key.auth.failed— fired on a failed verify;Event.Reasonis one of:not_found,expired,tenant_mismatch,wrong_type. No token or hash is included.api_key.purged— fired by theDeleteExpiredGC sweep;Attrscarry"count".
Opt-in route gates (
WithRequiredKind,WithRequiredScopes,RequireMachine,RequireHuman) areAuthOptions onRequireAuth. They are entirely opt-in: the library imposes no default authority policy. A request carrying a PAT when the route requires a Service token (or vice versa) receives403 wrong_principal_kind; a request missing a required scope receives403 insufficient_scope. Theegauth.Actorinjected into every handler always carriesKind,KeyID, andScopes, so application code can also enforce policy directly without relying on middleware gates. -
Magic-link login reuses the single-use selector/verifier verification tokens; the request endpoint is uniform (no account enumeration) and delivery is dispatched off the response path, exactly like the password-reset request.
-
Independent recovery channels (breaking the single-email takeover chain). An account can enroll a verified phone (
RequestPhoneVerification) and/or a verified recovery email (RequestRecoveryEmail) — both proven by a token delivered to that channel before it is trusted, and a recovery email may not equal the primary address (ErrRecoveryEmailIsPrimary). The recovery email is a contact attribute, not a login key (it is not unique, never re-keys an identity, and cannot be authenticated against).RecoveryChannels(...).Any()is the gate primitive: pair it with a freshness/step-up check (tokens.WithMaxAuthAge) to require an independent verified channel before a sensitive factor-reset.RequestPasswordResetViaRecoverydirects the reset token to a verified recovery channel instead of the primary inbox, so a compromised primary mailbox cannot drive the reset; it is enumeration-uniform — an unknown account, an OAuth-only account, and a known account with no recovery channel all produce the same empty, no-error response. -
Deactivation revokes pending tokens and blocks re-authentication. Magic-link, password-reset and email-verification all reject a token whose account has since been soft-deleted (
DeleteUser): the consume path re-checksDeletedAtand returns "not found", so deleting an account reliably invalidates its outstanding passwordless logins and reset links.LinkOrCreateIdentity's already-linked branch likewise re-checksDeletedAtand returns "not found", so a deleted account cannot regain a session through its previously-linked OAuth identity. To make this gate reachable,DeleteUseronly anonymizes theprovider_idof password-provider identity rows (theprovider_idfor password identities is the user's email address, which is PII); non-password (OAuth/OIDC) identityprovider_idvalues are opaque external subject identifiers and are preserved intact so thatFindIdentityByProvidercan still locate the identity after deletion, allowing theDeletedAtcheck to fire. -
One-time passcodes (email/SMS OTP). The
otpmodule is delivery-agnostic — egauth never sends anything;Issuereturns the plaintext code for the application to deliver, andVerifyis single-use and attempt-limited (the code is burned afterMaxAttemptswrong guesses). Both guarantees hold under concurrency: success consumes the code through an atomic guarded delete keyed on the exact hash that was compared (only one of N parallel correct-code verifications wins), and an attempt slot is reserved atomically before the code is compared, so concurrent wrong guesses cannot exceed the limit. The hash guard also covers the Issue/Verify interleave: if the code is reissued between a verifier's read and its consume, the stored row now carries a different hash, so the stale verification deletes nothing and fails — a superseded code can neither be accepted nor burn its freshly issued replacement.NewServicepanics at construction ifWithDigitsis called with a value outside [6, 10]: values below 6 produce a trivially guessable code space (a 5-digit code has only 100 000 candidates, giving a 50 % win rate with 5 attempts); values above 10 cause big.Int allocations with no security benefit. Most authenticator apps support only 6 and 8 digits. Because numeric OTPs are intentionally low-entropy, the at-rest SHA-256 hash is not a barrier against an attacker who already has the database; the real defenses are the short TTL, single-use consumption and the attempt limit — and, as always, the consumer's own rate limiting on the verify endpoint. -
No internal logging. egauth performs no logging of its own ("silent by default"). It never writes passwords, plaintext tokens, or hashes to stdout/stderr or any logger.
context.Contextis propagated so consumers can attach their own tracing. -
Context cancellation is observed on the expensive paths.
context.Contextflows through every operation. I/O cancellation propagates through the driver (pgx for the Postgres stores;net/httpfor the HIBP breach client, which useshttp.NewRequestWithContext). In addition, the deliberately expensive in-process paths checkctx.Err()before doing the costly work, so a client that has already gone away cannot keep burning resources: the Argon2id KDF (passwords/argon2Hash/Compare) short-circuits before the hash pass, the offline breach lookup fails fast, andidentity.DeleteAccountaborts its cross-module cascade before running another eraser (leaving the account live and the operation cleanly retriable). Argon2id itself is not interruptible mid-hash, so the guard is a pre-call check, not a kill switch for an in-flight pass; in-memory map lookups in the reference stores are not individually cancellable but complete in microseconds. -
Argon2id cost parameters from stored hashes are bounds-checked on both sides.
Compareparses them/t/pcost fields from the stored PHC string and validates them before invokingargon2.IDKey. Lower bounds (time ≥ 1, threads ≥ 1, memory ≥ 8×threads) prevent library panics. An upper bound (MaxMemoryKiB= 512 MiB = 524 288 KiB) prevents an OOM DoS:argon2.IDKeyallocatesmemory × 1 024bytes, so a tampered or corrupt stored hash row carrying e.g.m=4000000000would attempt a multi-TiB allocation on the victim's next login. Any stored hash whose memory parameter exceedsMaxMemoryKiBis rejected asErrInvalidPassword(same opaque mismatch signal as all other validation failures) before the KDF is invoked. -
Redaction on credential-bearing types (defence in depth). The structs most likely to be logged or printed implement
fmt.Stringer/fmt.GoStringerandslog.LogValuerso their secret fields render asREDACTEDon the accidental-leak paths (%v/%s/%+v/%#v,log,slog):tokens.TokenPair(access + refresh token),tokens.APIKey(the clear-textToken),tokens/jwt.Config,tokens/jwt.SigningKeyand the runningtokens/jwt.Service(SecretKey/SigningKeys[].Secretand the resolved key bytes),webapp.Config.SigningKey,passkey.Config.CookieKey, theoauth.Providerclient secret,keystore.SigningKey/keystore.Keyset(Secret), andmfa.TOTPEnrollment.Secret. Non-secret identifiers (key IDs, issuer, tenant, endpoints, expiry) stay visible to aid debugging. This is a safety net, not a licence to log these values (see below). JSON marshalling is intentionally not redacted, since returning a freshly issued token to its owner in a response body is a legitimate use. -
Trivially known signing keys are rejected at construction.
tokens/jwt(and therefore thekeystoreJWT adapter, which projects each key throughjwt.NewHMACSigner) refuses an HS256 secret that is all-zero or a single repeated byte in addition to one shorter thanMinSecretKeyLengthor matching a published example key.Config.InsecureAllowWeakKeysuppresses only the minimum-length gate — the published-key denylist and the trivially-known-key check are unconditional. -
Errors do not echo secrets. Wrapped errors carry the underlying cause (
%w) or non-sensitive metadata (e.g. a JWTalgheader), never the plaintext password or token bytes.
Some values are returned to the caller in plaintext exactly once and must be treated as credentials:
tokens.APIKey.Token— the raw API key (onlyAPIKey.Hashis stored).tokens.TokenPair.AccessTokenandtokens.TokenPair.RefreshToken.- Session tokens returned by the
sessionsservice (barestringreturn values, not a struct field —sessions.Sessionpersists onlyTokenHash). - Any password passed into
Register/Authenticate.
The key-bearing structs above redact their secret fields on fmt/slog (see the redaction note
above), but a session token / password is a bare string with no such safety net, and the
redaction is in any case only a backstop. Therefore the consumer must:
- Never log them (no
log,slog,fmt.Printf, request/response dumps, etc.). The redaction stops an accidental struct dump; it does not make logging a token's value safe. - Never serialize them by accident. The key-bearing structs carry no
jsontags and JSON marshalling is deliberately not redacted, so a consumer that JSON-encodes them will emit the plaintext. Send a token to the client deliberately (cookie/body) and nowhere else. - Never log key material. Load
tokens/jwt.Config.SecretKey/SigningKeys,webapp.Config.SigningKey,passkey.Config.CookieKey,oauth.Provider's client secret andkeystore.SigningKey.Secretfrom a secret store; those types redact the secret onfmt/slog, but do not serialize a key-bearing config or persist a signing key in plaintext.mfa.TOTPEnrollment.Secretis deliberately recoverable so the server can recompute codes — it too is redacted onfmt/slog, but must still be encrypted at rest and treated as a credential. - Transmit only over TLS and store client-side tokens in
HttpOnly,Securecookies (the HTTP handlers set these flags by default). - Access-token tenant binding (fail-closed when multi-tenant). When one
tokens/jwt.Servicesigns for every tenant under a shared key, a token minted for tenant A is cryptographically valid in tenant B's context. The tenant-unawareVerifyAccessTokenperforms no tenant comparison and is deprecated. Settokens/jwt.Config.MultiTenant = trueso thatVerifyAccessTokenfails closed withtokens.ErrTenantBindingRequired, and callService.VerifyAccessTokenForTenant(ctx, tenantID, token)— it binds the signedtenant_idclaim to the request tenant and rejects a mismatch withtokens.ErrTenantMismatch. The HTTP middleware exposes the same guarantee:tokens.RequireAuthwithtokens.WithAuthTenantResolverresolves the request's tenant and verifies throughVerifyAccessTokenForTenant. The resolver is fail-closed — returning""(tenant could not be resolved) rejects the request with401and never falls back to the tenant-unaware path, so a multi-tenant verifier is never reached unbound. Genuinely single-tenant deployments leaveMultiTenantfalse (every token is issued under the empty tenant), configure no resolver, and may keep usingVerifyAccessTokenor theSingleTenantwrapper. __Host-cookie name prefix — the tokens package defaults to__Host-access_tokenand__Host-refresh_token(DefaultAccessCookieName/DefaultRefreshCookieName). Browsers enforce that a__Host-cookie is host-locked:Secure, noDomainattribute, andPath=/. This defeats subdomain/sibling-host cookie-tossing / refresh-token fixation, where an attacker onevil.example.complants aDomain=.example.com refresh_tokencookie containing the attacker's own token — the victim's auto-refresh then rotates the attacker's family and silently signs the victim into the attacker's session.tokens.Cookies.Validate()rejects any configuration that pairs a__Host-cookie name withDomain != "",Path != "/", orInsecure == true;withDefaults(called by every Set*/Clear*/Access/Refresh method) panics on such a mismatch, surfacing the programmer error at development time. For thesessionspackage:sessions.RequireSessionnow reads the session token fromsessions.DefaultSessionCookieName("__Host-session_token") by default — the hardened host-locked name is automatic and you no longer opt in.sessions.WithCookieNameis an escape hatch for deployments that genuinely cannot satisfy the__Host-requirements (e.g. a path-scoped cookie or local plain-HTTP development); overriding to a name without the prefix forfeits the host-lock hardening and is the consumer's explicit choice.- OAuth state cookie carries secrets in plaintext, but is authenticated and host-locked.
The short-lived OAuth
statecookie (default name__Host-oauth_state) is a plain concatenation of the CSRF state, the PKCE code verifier, the OIDC nonce, the provider name and the tenant — it is not encrypted, but it is HMAC-SHA-256 authenticated and host-locked by the__Host-prefix.WithStateSigningKeyis required and must supply at leastoauth.MinStateSigningKeyLength(32) bytes; the handlers fail closed with500when the key is missing or too short, so a cookie an attacker can plant (sibling-subdomain tossing, plaintext HTTP) cannot drive a forged login, and a short key cannot be brute-forced offline from a captured cookie. On callback thestatebinding is additionally compared in constant time. Two consequences for the consumer:- Never log or mirror request cookies. The verifier and nonce sit in the cookie in plaintext; any infra that logs cookies, ships them to an observability backend, or proxies them through something that persists headers is recording sensitive material.
- Do not move
stateout of the cookie without re-deriving the guarantee. If you refactor it to a server-side handle, a header, or a differently-prefixed cookie, you can silently lose the authenticity/host-lock protection the current scheme depends on. The state cookie staysHttpOnly+Secure+SameSite=Lax, and__Host-by default. A deployment that must share the in-flight state cookie across subdomains opts out explicitly withoauth.WithCookieDomainplus a non-__Host-oauth.WithStateCookieName, accepting the cookie-tossing residual;oauth.ValidateHandlerConfigreports both misconfigurations at startup.WithInsecureCookies(local HTTP development only) is likewise incompatible with the default name — rename the cookie when serving plaintext HTTP.
- Session absolute lifetime.
sessions.NewServiceenforces a 30-day absolute session lifetime by default (OWASP session guidance: an absolute timeout must complement the idle timeout). Regardless of how recentlyTouchwas called, a session is rejected oncenow > CreatedAt + 30d. Usesessions.WithMaxLifetime(d)to shorten or lengthen this cap. Usesessions.WithNoMaxLifetime()to disable it entirely — this is insecure: an attacker who keeps a stolen token warm with periodic requests can extend the session forever, and should only be used in explicitly documented, low-risk contexts.WithMaxLifetime(0)is treated as "keep the default" (not "disable"), so callers that pass a configurable duration do not silently opt out of the cap when the user configures zero.
LoginHandler, RegisterHandler, the authenticated identity mutations
(ChangePasswordHandler, change-email, delete-account, recovery, phone/email
verification), RefreshHandler, LogoutHandler, sessions.RequireSession, the mfa
handlers, the otp handlers and passkey.RenameCredentialHandler are all state-changing
endpoints driven by the request (form body / cookies). egauth does not ship a full
CSRF-token system (per the PRD, that is left to the application layer), but it now applies a
strict same-origin check on every one of these handler families by default:
- Same-origin is enforced even with no configuration. A state-changing POST is allowed
only when its
Origin(orRefererfallback) host equals the request's ownHostor an explicitly allow-listed host. A browser-driven POST carrying neitherOriginnorRefereris treated as untrusted and rejected with403 cross_site_blocked. This closes the login-CSRF / session-fixation gap (whereSameSitedoes not help, because the attack needs no pre-existing cookie) and the MFA/OTP downgrade gaps (a cross-site POST toDisableHandler/RegenerateRecoveryCodesHandlerstripping a victim's second factor) out of the box — the previous behavior, where an empty allowlist disabled the check, is gone. SameSite=Laxcookies (default) remain a second layer: they stop a cross-site request from sending the refresh/session cookie, protectingRefreshHandler/LogoutHandleragainst classic CSRF on an existing session.WithTrustedOrigins(...)(onidentity,tokens,mfa,otp,sessions,passkey) widens the same-origin allowlist to additional hosts — e.g. a front-end served from another subdomain. Supply hostnames without scheme, e.g.identity.WithTrustedOrigins("app.example.com").WithInsecureNoOriginCheck()(onidentity,tokens,mfa,otp,sessions,passkey) is the explicit, loudly-named opt-out: it disables the same-origin check entirely, restoring the pre-v1 accept-all behavior. Only reach for it when CSRF is handled by a separate layer (e.g. a synchronizer/double-submit token middleware) or in trusted test setups.sessions.RequireSessiongates only cookie authentication. When the session token comes from the ambient cookie, unsafe methods are subject to the same-origin check above; when it comes from anAuthorization: Bearerheader the request is exempt, because a header credential is non-ambient and a cross-site attacker cannot make the browser attach it. The check runs beforeValidateSession, so a forged request never reaches the store or the protected handler.
The webapp v1 preset (webapp.NewWebApp) carries this guarantee across both handler
families it mounts: it refuses to build when Config.TrustedOrigins is empty unless you
explicitly set Config.InsecureNoOriginCheck, in which case the opt-out is wired into both
the identity and tokens handlers so the preset is consistently insecure rather than protecting
only one half. This makes "CSRF-by-default" mean the same thing across every endpoint the
preset exposes.
The mfa handlers (EnrollHandler, ConfirmHandler, VerifyHandler,
VerifyRecoveryHandler, RegenerateRecoveryCodesHandler, DisableHandler,
StepUpHandler) are also state-changing POST endpoints, and — like the identity and token
handler families — they enforce the strict same-origin check by default. Even with no
configuration, a cross-site form POST that would otherwise silently strip a victim's second
factor (MFA downgrade via DisableHandler) or invalidate their recovery codes
(RegenerateRecoveryCodesHandler) is rejected with 403 cross_site_blocked. Use
mfa.WithTrustedOrigins(...) to widen the Origin/Referer host allowlist to additional
hosts when the MFA endpoints are reachable from a browser session on another origin (e.g. a
cross-subdomain or embedded app); supply hostnames without scheme, e.g.
mfa.WithTrustedOrigins("app.example.com"). The check is turned off only via the explicit
mfa.WithInsecureNoOriginCheck() opt-out.
The passkey.RenameCredentialHandler is the one passkey mutation outside the WebAuthn
ceremony-cookie protection, and it enforces the same strict same-origin check by default: a
cross-origin POST is rejected with 403 cross_site_blocked (a request carrying neither Origin
nor Referer is untrusted), before the body is decoded or the service is called. Widen with
passkey.WithTrustedOrigins(...); disable only via the explicit
passkey.WithInsecureNoOriginCheck() opt-out. As defense in depth it also requires
Content-Type: application/json (415 otherwise), so a CORS-simple text/plain form POST cannot
smuggle the JSON body. The WebAuthn ceremony handlers (Begin/Finish registration/login) are not
subject to this gate because they are already protected by the HMAC-sealed
__Host-passkey_ceremony cookie and go-webauthn's own origin validation.
The à-la-carte handlers (identity, mfa, otp, tokens, passkey) are policy-free about
throttling: egauth exposes the ratelimit.Limiter seam and the ratelimit.Middleware /
ratelimit.Wrap helpers, and the consumer decides the policy. The webapp v1 preset
(webapp.NewWebApp) is different: it applies a per-client-IP throttle to every endpoint it
mounts (login, register, refresh, logout) by default, so the shipped preset cannot accidentally
expose unthrottled credential guessing or a refresh/registration flood:
- One process-local
ratelimit.TokenBucketis shared across the mounted routes, keyed byratelimit.ClientIP(the request'sRemoteAddr;X-Forwarded-Foris not trusted), with a burst ofDefaultRateLimitBurst(20) and one request restored everyDefaultRateLimitRefill(6s) — about 10 requests/minute per IP sustained. A rejected request is answered429 Too Many Requestswith aRetry-Afterheader before it reaches the handlers. - Tune the default with
Config.RateLimitBurst/Config.RateLimitRefill, or replace it with a shared-store implementation (e.g. Redis) viaConfig.RateLimiterfor multi-instance deployments. Behind a trusted proxy the default keys on the proxy's address; wrap the returned handler with your own proxy-aware limiter if per-forwarded-client keys are required. - Opt out only with the explicit
Config.InsecureNoRateLimit, and only when an upstream proxy, WAF or middleware already throttles those routes. Supplying bothRateLimiterandInsecureNoRateLimitis rejected at construction.
Observability — wire event.Sink to your metrics pipeline or audit log. The ready-made
event.NewSlogSink covers the "log it with slog" case. For OpenTelemetry tracing, egauth ships
a reference adapter at github.com/JLugagne/egauth/adapters/otel:
import (
"go.opentelemetry.io/otel"
egauthotel "github.com/JLugagne/egauth/adapters/otel"
"github.com/JLugagne/egauth/event"
)
tracer := otel.Tracer("egauth")
sink := egauthotel.NewSpanSink(tracer)
// Fan out to both slog and spans:
combined := event.MultiSink(event.NewSlogSink(nil), sink)NewSpanSink creates one child span per security event (auth success/failure, MFA, refresh
rotation, token-family revocation, insecure-cookie misuse, etc.) with egauth.* attributes and
records errors via span.RecordError. For Prometheus counters or SIEM ingestion, implement
event.Sink directly or use event.MultiSink to fan out. Every operation propagates a
context.Context, so span propagation and deadline enforcement are fully under the consumer's
control.
Idempotency — request-level deduplication (idempotency keys, retry-safe mutations) is the application layer's responsibility. egauth provides no idempotency-key layer; consuming applications that need it must implement or proxy one in front of the egauth handlers, mirroring how rate limiting and CSRF tokens are positioned.
The in-memory backends (sessions/memory, otp/memory, identity/memory, mfa/memory,
tokens/memory) and ratelimit.TokenBucket are bounded by default so a flood of
short-lived sessions, OTP/verification tokens, recovery attempts, refresh tokens, or unique
rate-limit keys cannot exhaust heap memory:
sessions/memory,otp/memory,identity/memoryandtokens/memorydefault to a cap ofDefaultMaxEntries(100,000). At the cap they evict expired records first and then the soonest-expiring record;sessions/memorynever evicts live sessions and instead fails the insert withsessions.ErrStoreCapacityExceeded. Durable account records (users, identities, MFA enrollments, recovery codes, API keys) are never evicted.mfa/memorycaps recovery-attempt records atDefaultMaxEntries, evicting the stalest first; TOTP enrollments and recovery codes are durable.ratelimit.TokenBucketcaps tracked keys atDefaultMaxKeys(100,000) and evicts the least-pressured bucket.
The bounded defaults are deliberately generous; ordinary single-process use never reaches them.
Callers that prefer to own eviction can opt into the unbounded model explicitly with
NewUnboundedStore() (memory stores) or WithMaxKeys(n) (ratelimit), and must then schedule
periodic eviction using the optional janitor helper shipped with egauth:
import "github.com/JLugagne/egauth/janitor"
j := janitor.Start(ctx, 5*time.Minute, func() {
sessStore.DeleteExpired(context.Background(), tenantID)
})
defer j.Stop()The same pattern applies to otp/memory.Store.DeleteExpired,
identity/memory.Store.DeleteExpiredVerificationTokens and ratelimit.TokenBucket.Cleanup.
The per-read opportunistic eviction in sessions/memory only evicts the single looked-up entry;
it is O(1) on the hot path and is not a substitute for a full sweep in the unbounded model.
See package janitor for multi-tenant and multi-store usage examples. Deployments that need
persistence or horizontal scaling should use the pgx backends instead of the in-memory stores.
passwords.BreachChecker is a hook — egauth ships the interface and makes no network calls
itself. When you wire a HIBP (or other) client into the password policy and that client errors
(service down, timeout, rate-limited), the policy propagates the raw error unchanged. How
your handler reacts to that error is an explicit security-posture decision with no safe default,
and it is invisible unless you look for it:
- Reject on any policy error → fail-closed. A breach-service outage blocks every registration and password change (an availability hit), but no unscreened password is ever accepted.
- Special-case only
ErrPasswordBreachedand let other errors pass → fail-open. An outage silently disables breach screening and weak/known-breached passwords sail through — and because nothing fails loudly, a fail-open can go unnoticed for months.
Neither is wrong; the choice depends on whether you value availability or screening guarantees
more. Consumer guidance: decide deliberately, wrap your IsBreached implementation in a
timeout so a hung upstream cannot stall the auth path, and log/alert when it errors so a
silent fail-open is observable. Treat the breach check as advisory defence-in-depth on top of the
length/denylist policy, not as the primary control.
The library's most important runtime guarantees — refresh-token single-use (replay detection),
TOTP single-use, and failed-attempt lockout — are enforced in the service layer but depend on
the store implementing specific methods atomically. The bundled pgx adapters do this correctly
(ConsumeRefreshToken is an UPDATE … WHERE consumed_at IS NULL; MarkTOTPUsed is a
compare-and-set on a strictly increasing step; IncrementFailedAttempts is a single atomic
UPDATE whose post-increment result drives the lock decision and the account.locked event).
These contracts are documented in the store interface comments, not enforced by the compiler. If you write your own adapter (or modify the bundled one) and implement any of these as a non-atomic read-then-write, you silently break the guarantee — replay detection stops working, a TOTP code becomes reusable, or the lockout audit event mis-fires — and nothing fails loudly.
Consumer guidance: treat those methods as concurrency-critical and test them under parallel
load. The repo ships contract test suites for exactly this — run identity/storetest,
tokens/storetest, mfa/storetest, and the equivalent per-module suites against your adapter;
they assert the atomic behaviours (including that IncrementFailedAttempts reports the locking
transition exactly once) that the service layer relies on.
Three responses intentionally reveal that an account exists; this is an accepted trade-off, not a bug:
ErrAccountLocked/ErrAccountDisabled→ 429 on login: lockout and administrative suspension are both meant to be observable (PRD §105–106). Both map to the same 429 response so suspended accounts are indistinguishable from locked ones to an external observer. Note this disclosure is at the status-code level by design. The login path additionally spends a decoy Argon2id hash on the locked and disabled rejection branches (matching the unknown-user / wrong-password paths) so the response time of those branches does not become a second, redundant enumeration oracle — keeping all in-process timing uniform and robust against a future refactor that collapses the 429 back to a generic 401.email_taken→ 409 on registration: standard registration UX. If your threat model requires anti-enumeration on sign-up, collapsemapRegisterErrorto a single generic400(note thatRegisteralready hashes before the uniqueness check, so the timing channel is already closed).email_taken→ 409 on the authenticated change-email request (RequestEmailChangeHandler): the caller is told up front when the requested new address already belongs to another account, mirroring the registration disclosure. This is gated behind authentication (a higher bar than sign-up) and gives the user a clear "pick another address" response. If your threat model forbids it, drop the pre-flightFindUserByEmailconflict inRequestEmailChangeand rely solely on the store's unique index at confirm time (ConfirmEmailChangealready returnsErrEmailAlreadyExistsfor an address claimed in the interim).no_credentials→ 400 onBeginLoginHandler: when the resolved user has no registered passkeys,BeginLoginreturnsErrNoCredentials, whichBeginLoginHandlermaps to HTTP 400no_credentials. A user with at least one passkey receives HTTP 200 plus a challenge. A caller that can drive the begin-login endpoint with a chosen/identified userID can therefore distinguish "account has passkeys" from "account has none" — a passkey-enrolment enumeration oracle. This is an accepted trade-off: WebAuthn UX fundamentally requires the server to know whether the account has any credential before issuing a challenge, and a silent generic error would break the client flow. Consumer guidance: gateBeginLoginHandlerbehind per-IP or per-subject rate limiting (egauth does not throttle ceremony attempts — see the hardening checklist above). If your threat model forbids passkey-enrolment enumeration, change yourUserResolverto returnok=false(→ 401) for unenrolled subjects before the handler reachesBeginLogin, or handleErrNoCredentialsyourself and return a generic 400 without theno_credentialsbody.
The login path itself is hardened against enumeration (generic ErrInvalidCredentials +
decoy hashing); the four disclosures above are the only intentional exceptions.
The decoy-hashing defence is not perfectly uniform across a cost upgrade. The decoy path
(the hash run for an unknown account) always uses the hasher's current configured
cost — Hash bakes in m/t/p from the live Hasher. The real verify path (Compare)
runs Argon2id at the cost recorded in the stored hash, which for a not-yet-rehashed
account is the old, lower cost. So immediately after an operator raises the cost
parameters (the documented rehash-on-login upgrade), every existing account whose hash has
not yet been rehashed verifies at the old (faster) cost, while an unknown account is
decoy-hashed at the new (slower) cost. The measurable timing gap is a partial enumeration
oracle: it can distinguish "registered account that last logged in before the cost bump"
from "unknown". The gap closes for each account the moment it next authenticates and its hash
is transparently rehashed at the new cost, and it disappears entirely once the population has
refreshed.
Operator guidance. When you raise Argon2id cost, treat enumeration-resistance as
degraded until the fleet is rehashed. Either proactively re-hash all stored passwords to
the new cost (so no account verifies at the old cost), or accept the degraded
enumeration-resistance during the natural rehash-on-login refresh window. As with all residual
in-process timing in egauth, the standing mitigation is the consumer's own rate limiting on
the login endpoint (per the non-objectives), which covers the remainder.
The password-reset request endpoint (RequestPasswordResetHandler) is, by contrast,
deliberately uniform: it returns the same response for a known account, an unknown account, an
OAuth-only account (no password to reset), and even a backend error — and it dispatches email
delivery off the response path so the Mailer's latency is not a timing oracle. Account existence
must not be inferable from this endpoint. (Residual in-process timing — one extra indexed DB
read for an existing account — is left to the consumer's rate limiting, per the non-objectives.)
The library claims constant-time behaviour for secret-dependent comparisons (password verification, opaque-token equality, OAuth state/PKCE/nonce binding, ceremony-cookie and flow-token authentication, one-time-code checks). Those claims currently rest on two arguments, neither of which is a machine-checked proof:
-
Structural by construction. Every secret-dependent comparison in the hand-written glue reaches a constant-time primitive, and the code does not branch on the comparison outcome:
- password verification always reaches
crypto/subtle.ConstantTimeCompare(passwords/argon2), and the account-existence paths run a full decoy Argon2id pass (identity) so an unknown user costs the same as a known one; - signed-cookie and flow-token tags are compared with
hmac.Equal(passkey.handlerConfig.open,authflow.decodeFlowToken), and each HMAC is computed over the full input regardless of where a mismatch occurs; - OAuth
state, provider and tenant bindings usesubtle.ConstantTimeCompare(oauth.stateMatches), as do password-reset/email-verification verifiers, OTP hashes and TOTP codes; - JWT verification selects the signer by
kidand pins the algorithm before verifying, so there is no algorithm-confusion branch, and delegates signature comparison togolang-jwt(hmac.Equalfor the HMAC signers).
This is reviewable by inspection and grep, but inspection can miss a branch.
- password verification always reaches
-
Benchmark evidence. Timing benchmarks compare correct vs wrong inputs and valid vs unknown users (
BenchmarkCompare_CorrectPassword/_WrongPasswordinpasswords/argon2;BenchmarkAuthenticate_*inidentity). These are manual evidence, not a CI gate; a benchstat-significant gap signals a regression and should be investigated (see "Running the timing-evidence benchmarks" above).
What would strengthen the argument. A statistical timing analysis (for example a dudect-style test) on dedicated, low-noise hardware across several CPU families would detect microarchitectural leakage (cache and branch-predictor effects) that structural review and wall-clock benchmarks cannot. Gating such a test in CI is not proposed: shared runners are too noisy for a meaningful threshold, and a flaky gate invites ignoring real regressions.
Follow-up maintainer action. An independent review of the HMAC constructions and the custom protocol glue by someone other than the author remains outstanding; since the evidence above is structural, consumers with a higher assurance requirement should pin a reviewed commit or commission their own review.
Release tags are signed — keyless Sigstore/gitsign by default, with OpenPGP or SSH as supported alternatives — and SBOM release assets can be attested. Verify both before trusting a build.
Tag signature (keyless Sigstore).
go install github.com/sigstore/gitsign@latest # or: brew install gitsign
git config --global gpg.x509.program gitsign
git config --global gpg.format x509
git verify-tag vX.Y.Z # cryptographic integrity + Sigstore transparency-log inclusion
gitsign verify \
--certificate-identity=<maintainer-identity> \
--certificate-oidc-issuer=https://github.com/login/oauth \
vX.Y.Z # also verifies *who* signed itgit verify-tag returns 0 for a cryptographically valid signature but does not check the
certificate claims; gitsign verify performs the full identity check against the
--certificate-identity recorded in the release notes. First-time keyless verification may
need network access to refresh the local Sigstore trust root; afterwards it works offline.
For OpenPGP or SSH signatures, import (or allow-list) the published public key and run
git verify-tag vX.Y.Z; see RELEASING.md Step 5 for setup.
Release artifacts (SBOM).
# GitHub artifact attestations (once the release workflow attests the artifacts):
gh attestation verify libauth-vX.Y.Z.sbom.json --repo JLugagne/egauth
# or a keyless cosign bundle attached to the release:
cosign verify-blob \
--bundle libauth-vX.Y.Z.sbom.json.sigstore.json \
--certificate-identity=<maintainer-identity> \
--certificate-oidc-issuer=https://github.com/login/oauth \
libauth-vX.Y.Z.sbom.jsonMaintainers run the same gate as consumers before pushing a tag:
bash scripts/verify-release-tag.sh <tag> fails on a missing, lightweight or unsigned tag.
Historical gap. Tags up to and including
v0.11.0, including alladapters/pgxtags, predate signing and are unsigned (adapters/pgx/v0.6.1is even a lightweight tag) —git verify-tagfails on them. They cannot be signed retroactively; treat them as unverified and prefer the first signed release.
Please use GitHub Private Vulnerability Reporting — do not open a public issue for security matters.
- Go to the repository's Security tab.
- Click "Report a vulnerability".
- Fill in the advisory form and submit.
Direct link: https://github.com/JLugagne/egauth/security/advisories/new
Note: GitHub Private Vulnerability Reporting must be enabled in the repository's Security settings for the button to appear; this is verified separately.
Only the latest 0.x minor release receives security fixes. Older minor series are
unsupported and may be retracted.
| Version | Supported |
|---|---|
| >= 0.3 | yes |
| 0.2.x | no (unsupported) |
| 0.1.x | no (retracted) |
Security reports are acknowledged on a good-faith, best-effort basis:
- 72 hours — initial acknowledgement (confirm receipt and assign a tracking ID).
- 7 days — preliminary assessment (severity, reproduction status, and an estimated fix timeline).