A Go authentication library providing JWT based authentication, password management, and role based access control with support for resource level permissions.
- JWT token generation and validation with structured claims
- Password hashing and verification using bcrypt
- User registration and login with email validation
- Password reset flow with email verification
- Role based access control (RBAC) with hierarchical permissions
- Resource level permissions for fine grained access control
- HTTP middleware for route protection
- Built in authentication controllers for web applications
- Database persistence layer using Bun ORM
- OAuth2 social login (GitHub, Google) with account linking
- Customizable identity providers and role providers
- Store backed permission resolver cache with pluggable backends
- OIDC/provider sessions, lifecycle coordination, assurance/freshness and audit hooks
Use Go 1.26.0 or newer as required by go.mod.
| Package/module | Ownership | Guide |
|---|---|---|
Root auth, repository, middleware |
Local tokens/passwords, account state, controllers, actor context, permission/freshness contracts and storage | Sections below; middleware |
social |
Provider OAuth account links in social_accounts |
Social login |
provider/oidc |
Browser OIDC, immutable provider identity linking and local-token/provider-session callback modes | OIDC |
provider/auth0 |
Auth0 JWT validation and optional identity mirroring | Auth0 |
provider/supabase |
Supabase identity, session/factor and lifecycle capability adapters | Supabase |
activitymap |
Producer event normalization; downstream disclosure/retention remains host-owned | Activity mapping |
adapters/featuregate |
Separate module mapping actor context into feature claims/permissions | Featuregate adapter |
adapters/goadmin |
Separate module mounting browser SSO and account-link/logout UI routes | Admin adapter |
The root module does not import go-admin. Install optional adapter modules only
when those integrations are needed; check their own go.mod and release tags.
examples is a separate runnable-example module.
Provider sessions are server-side records with encrypted token material and opaque browser handoffs. OIDC state/nonce/PKCE, lifecycle fences and operations, refresh/revocation and identity binding have explicit storage/policy contracts; configuring an external token validator alone does not assemble this runtime. The OIDC and Supabase guides describe callback modes and migration requirements.
When an IdentityProvider is configured, SessionFromToken and the HTTP
protected-route middleware re-read the signed user ID and reject inactive,
missing or mismatched identities and provider lookup errors. This adds one
identity lookup per successfully validated token. Use
SessionFromTokenContext(ctx, token) to pass request cancellation to that lookup.
A nil or typed-nil provider keeps token-validation-only behavior; low-level
TokenService.Validate remains a cryptographic check.
JWT validity also does not prove current authorization. Sensitive operations
can compose AuthorizationFreshnessGuard with authoritative session/account,
assurance and permission-version resolvers, invalidation and explicit operation
policy. Hosts own step-up delivery and dependency-outage decisions. See
authorization_freshness.go, freshness_policy.go, and
freshness_invalidation.go; local passwords and external provider sessions use
separate credential lifecycles.
go get github.com/goliatone/go-authSupply a tracker implementing both UserTracker and
AtomicLoginAttemptTracker; password verification requires atomic attempt
reservation. RepositoryManager.Users() exposes additional query arguments and needs
a small adapter rather than being passed directly. See the tracker adapter in
the runnable example for Bun wiring.
package main
import (
"context"
"fmt"
"github.com/goliatone/go-auth"
)
type AppAuthConfig struct {
SigningKey string
SigningMethod string
ContextKey string
TokenExpiration int
ExtendedTokenDuration int
TokenLookup string
AuthScheme string
Issuer string
Audience []string
RejectedRouteKey string
RejectedRouteDefault string
}
func (c AppAuthConfig) GetSigningKey() string { return c.SigningKey }
func (c AppAuthConfig) GetSigningMethod() string { return c.SigningMethod }
func (c AppAuthConfig) GetContextKey() string { return c.ContextKey }
func (c AppAuthConfig) GetTokenExpiration() int { return c.TokenExpiration }
func (c AppAuthConfig) GetExtendedTokenDuration() int { return c.ExtendedTokenDuration }
func (c AppAuthConfig) GetTokenLookup() string { return c.TokenLookup }
func (c AppAuthConfig) GetAuthScheme() string { return c.AuthScheme }
func (c AppAuthConfig) GetIssuer() string { return c.Issuer }
func (c AppAuthConfig) GetAudience() []string { return c.Audience }
func (c AppAuthConfig) GetRejectedRouteKey() string { return c.RejectedRouteKey }
func (c AppAuthConfig) GetRejectedRouteDefault() string { return c.RejectedRouteDefault }
func authenticateExample(tracker interface {
auth.UserTracker
auth.AtomicLoginAttemptTracker
}) error {
userProvider := auth.NewUserProvider(tracker)
// Any struct that satisfies auth.Config works.
config := AppAuthConfig{
SigningKey: "your-secret-key",
SigningMethod: "HS256",
ContextKey: "auth",
TokenExpiration: 24, // hours
ExtendedTokenDuration: 24 * 7,
TokenLookup: "header:Authorization",
AuthScheme: "Bearer",
Issuer: "your-app",
Audience: []string{"your-audience"},
RejectedRouteKey: "redirect",
RejectedRouteDefault: "/",
}
authenticator := auth.NewAuthenticator(userProvider, config)
// Login user
token, err := authenticator.Login(context.Background(), "user@example.com", "password")
if err != nil {
return err
}
// Validate token and get session
session, err := authenticator.SessionFromToken(token)
if err != nil {
return err
}
fmt.Printf("User ID: %s\n", session.GetUserID())
return nil
}go-auth recognizes temporary password metadata on local users:
password_temporarypassword_change_requiredpassword_temporary_issued_atpassword_temporary_expires_at
UserProvider.VerifyIdentity rejects a matching password when password_temporary=true and the expiry is in the past, missing, or malformed. Before expiry, login succeeds so the host can redirect the user to a password-change flow. Add auth.TemporaryPasswordClaimsDecorator() to copy compact password-change hints into JWT/session metadata. Use TemporaryPasswordResetRepository.ResetPasswordAndClearTemporaryPassword when a permanent password reset must clear temporary-password metadata in the same transaction as the password hash update. Repositories that do not expose this optional primitive still support ordinary password resets for users without temporary-password metadata.
// Implement ResourceRoleProvider for fine-grained permissions
type CustomResourceRoleProvider struct {
repo auth.RepositoryManager
}
func (p *CustomResourceRoleProvider) FindResourceRoles(ctx context.Context, identity auth.Identity) (map[string]string, error) {
resourceRoles := make(map[string]string)
switch identity.Role() {
case "admin":
resourceRoles["admin:dashboard"] = "owner"
resourceRoles["project:default"] = "admin"
case "user":
resourceRoles["project:default"] = "member"
}
return resourceRoles, nil
}
// Use enhanced authenticator
authenticator = authenticator.WithResourceRoleProvider(&CustomResourceRoleProvider{repo: repoManager})
// Generated tokens will include resource-specific permissions
token, err := authenticator.Login(ctx, "admin@example.com", "password")// Create HTTP authenticator for web applications
httpAuth, err := auth.NewHTTPAuthenticator(authenticator, config)
if err != nil {
log.Fatal(err)
}
// Register browser authentication routes (login, register, password reset)
// with one stable package-managed CSRF contract.
err = auth.RegisterSecureAuthRoutes(
router.Group("/auth"),
auth.AuthRouteSecurityConfig{
CSRF: csrf.Config{
SecureKey: auth.DeriveBrowserCSRFSecureKey(config),
},
},
auth.WithControllerLogger(logger),
func(ac *auth.AuthController) *auth.AuthController {
ac.Auther = httpAuth
ac.Repo = repoManager
return ac
},
)
if err != nil {
log.Fatal(err)
}
// Protect routes with middleware
protected := httpAuth.ProtectedBrowserRoute(config, errorHandler)
router.Get("/profile", profileHandler, protected)RegisterAuthRoutes remains available for compatibility and now shares one
process-local stateless CSRF key across its route set. Prefer
RegisterSecureAuthRoutes with an explicit stable key for deployed browser
applications, especially when requests can reach multiple instances.
Success handler contract (jwt middleware)
The JWT middleware now invokes a SuccessHandler with the signature func(ctx router.Context, next router.HandlerFunc) error. The default simply calls next(ctx). If you override it, you are responsible for deciding whether to call next (run the protected handler) or short-circuit (e.g., redirect). Example:
protected := httpAuth.ProtectedRoute(config, errorHandler)
// Custom success hook that adds logging, then runs the handler
wrapped := func(ctx router.Context) error {
return protected(func(c router.Context) error {
log.Println("auth ok for", c.Path())
return profileHandler(c) // or c.Next() if chaining
})(ctx)
}
router.Get("/profile", profileHandler, wrapped)import (
"encoding/base64"
"github.com/goliatone/go-auth/social"
"github.com/goliatone/go-auth/social/providers/github"
"github.com/goliatone/go-auth/social/providers/google"
repo "github.com/goliatone/go-auth/repository"
)
stateEncKey, _ := base64.StdEncoding.DecodeString(cfg.Social.StateEncryptionKey)
stateHMACKey, _ := base64.StdEncoding.DecodeString(cfg.Social.StateHMACKey)
socialRepo := repo.NewSocialAccountRepository(bunDB)
socialAuth := social.NewSocialAuthenticator(
socialRepo,
repoManager.Users(),
authenticator.TokenService(),
social.SocialAuthConfig{
DefaultRedirectURL: "/",
StateEncryptionKey: stateEncKey,
StateHMACKey: stateHMACKey,
AllowSignup: true,
AllowLinking: true,
RequireEmailVerified: true,
},
social.WithProvider(github.New(github.Config{
ClientID: cfg.Social.GitHubClientID,
ClientSecret: cfg.Social.GitHubClientSecret,
CallbackURL: cfg.BaseURL + "/auth/social/github/callback",
})),
social.WithProvider(google.New(google.Config{
ClientID: cfg.Social.GoogleClientID,
ClientSecret: cfg.Social.GoogleClientSecret,
CallbackURL: cfg.BaseURL + "/auth/social/google/callback",
})),
)
socialController := social.NewHTTPController(socialAuth, social.HTTPConfig{
PathPrefix: "/auth/social",
SessionContextKey: cfg.Auth.GetContextKey(),
CookieName: cfg.Auth.GetContextKey(),
SuccessRedirect: "/dashboard",
ErrorRedirect: "/login?error=auth_failed",
CookieSecure: true,
CookieHTTPOnly: true,
CookieSameSite: "Lax",
})
socialController.RegisterRoutes(router.Group("/auth/social"))Routes registered by the controller:
GET /auth/social/providersGET /auth/social/:providerGET /auth/social/:provider/callbackPOST /auth/social/:provider/linkDELETE /auth/social/:providerGET /auth/social/accounts
See docs/SOCIAL_LOGIN.md for linking policies, migration guidance, and security notes.
Example wiring lives in examples/extensions/social_login.go.
Social OAuth account links use social_accounts. Browser SSO provider-subject
links use user_identifiers. Keep those tables distinct unless your host
implements an explicit migration or lookup bridge.
Generic OIDC browser SSO lives in provider/oidc; go-admin integration lives
in the nested adapters/goadmin module. See docs/OIDC.md for setup and
operational troubleshooting.
Auth0 support is opt-in and uses provider/auth0 for JWT validation and claims
mapping. For Auth0 + local tokens, use NewMultiTokenValidator to accept both.
import "github.com/goliatone/go-auth/provider/auth0"
auth0Validator, err := auth0.NewTokenValidator(auth0.Config{
Domain: "your-tenant.auth0.com",
Audience: []string{"https://api.yourapp.com"},
})
if err != nil {
return err
}
authenticator := auth.NewAuthenticator(userProvider, cfg.Auth)
// Accept Auth0 tokens only.
authenticator = authenticator.WithTokenValidator(auth0Validator)
// Or accept Auth0 + local go-auth tokens.
composite := auth.NewMultiTokenValidator(auth0Validator, authenticator.TokenService())
authenticator = authenticator.WithTokenValidator(composite)For sync (local mirroring), use provider/auth0/sync with
auth.IdentifierStore. See docs/AUTH0.md and examples/auth0/ for full
examples.
Auth0 validation errors normalize to ErrTokenExpired / ErrTokenMalformed
with provider=auth0 metadata. Use HasUserUUID(session) before calling
Session.GetUserUUID because Auth0 sub values are not UUIDs.
// Check permissions in handlers
func profileHandler(c router.Context) error {
session, err := auth.GetRouterSession(c, "auth")
if err != nil {
return err
}
// Enhanced session with resource-level permissions
if roleCapableSession, ok := session.(auth.RoleCapableSession); ok {
canEditSettings := roleCapableSession.CanEdit("admin:settings")
canDeleteUsers := roleCapableSession.CanDelete("admin:users")
hasAdminRole := roleCapableSession.HasRole("admin")
isAtLeastMember := roleCapableSession.IsAtLeast("member")
// Use permissions for authorization logic
}
return c.JSON(map[string]any{
"user_id": session.GetUserID(),
"data": session.GetData(),
})
}go-auth ships with a CSRF middleware that plugs into go-router and the template helper stack.
Construct a middleware instance once and reuse it for every route that
participates in the same form flow. The generated stateless signing key belongs
to that middleware instance; calling csrf.New() separately for the GET and
POST sides intentionally creates separate protection domains.
Stateless (default)
import (
csrf "github.com/goliatone/go-auth/middleware/csrf"
"github.com/goliatone/go-router"
)
func main() {
server := router.NewFiberAdapter()
app := server.Router()
// HMAC-signed tokens; a random key is generated once for this middleware.
app.Use(csrf.New())
app.Post("/submit", func(ctx router.Context) error {
// Token checked automatically
return ctx.SendStatus(router.StatusNoContent)
})
if err := server.Serve(":8080"); err != nil {
panic(err)
}
}Stateful (storage backed)
type redisStorage struct{ client *redis.Client }
func (r *redisStorage) Get(key string) (string, error) {
return r.client.Get(context.Background(), key).Result()
}
// Set/Delete omitted for brevity
app.Use(csrf.New(csrf.Config{
Storage: &redisStorage{client: redisClient},
Expiration: 24 * time.Hour,
}))Templates
// Inside your handler
viewCtx := auth.MergeTemplateData(ctx, router.ViewContext{
"title": "Secure form",
})
// MergeTemplateData copies the latest request scoped helpers so csrf_field,
// csrf_meta, etc. always render the token minted by the middleware.
return ctx.Render("form", viewCtx)
csrf_field,csrf_token, andcsrf_metaare now lazily evaluated helpers. When you registerauth.TemplateHelpers()globally, the template engine resolves the current request’s CSRF token automatically—no need to clone helper maps on every render (thoughMergeTemplateDataremains useful when you want a concrete snapshot).
<form method="post">
{{ csrf_field }}
<!-- other fields -->
</form>
<script>
const token = "{{ csrf_token }}";
const header = "{{ csrf_header_name }}";
fetch("/submit", {
method: "POST",
headers: { [header]: token },
});
</script><!-- Layout head section -->
{{ csrf_meta }}
<meta name="another-example" content="value" />For the explicit Fiber v3 view engine, register
auth.TemplateHelpersForPongo2V7() instead of auth.TemplateHelpers(). A shared
router view configuration can retain both runtime-specific maps:
cfg := router.NewSimpleViewConfig("views").
WithFunctions(auth.TemplateHelpers()).
WithFiberV3Functions(auth.TemplateHelpersForPongo2V7())The v7 helper map does not change the legacy factory. Both read the current
request's template_helpers map and return plain strings; normal template escaping
still applies. Use |safe only for the trusted markup generated by the CSRF
middleware (csrf_field and csrf_meta). Custom template integrations can select
a factory per renderer with csrf.CSRFTemplateHelpersWithFactory(factory); a nil
factory returns static placeholders without consulting or changing the default.
When auth claims are included in view locals, wrap the configured engine before installing it in the host's view configuration:
engine, err := router.InitializeFiberV3ViewEngine(cfg)
// Handle err, then use this as the host's Views value:
views := auth.NewTemplateViews(engine)NewTemplateViews is opt-in and also works with the v2 engine. It copies plain
render maps and slices, replacing auth claims at any key (including nested helper
data) with identity and permission methods, UID, UserRole, and copied resource
roles. Custom permission checks retain their original behavior. Request locals,
standard-context claims, actor data, and token validation remain unchanged.
Raw JWT metadata and extension methods are excluded from views; bind any extra
presentation fields explicitly as safe data. Other models and helpers still pass
through the renderer's normal safety checks. Configure engine-specific extensions
before wrapping the engine.
See middleware/csrf/README.md for more examples and configuration options (custom token lookup, skipping routes, etc.).
AJAX/SPA bootstrap endpoint
For clients that need to refresh tokens via XHR (e.g., SPAs), register the helper route:
import csrf "github.com/goliatone/go-auth/middleware/csrf"
csrf.RegisterRoutes(app.Router())The handler returns JSON:
{
"token": "...",
"field_name": "_token",
"header_name": "X-CSRF-Token"
}Responses include Cache-Control: no-store, so clients fetch fresh tokens as needed. Call this endpoint whenever a token expires or before issuing state-changing requests in a long-lived SPA session.
go-auth persists lifecycle metadata through two columns on users: status (pending, active, suspended, disabled, archived) and suspended_at (timestamp set whenever a user enters or exits the suspended state). The default UserStateMachine enforces the transition graph (archived is terminal, pending can only move to active or disabled, etc.), keeps timestamps in sync, and publishes ActivitySink events.
Use the shared Users repository helpers (UpdateStatus, Suspend, Reinstate) or work directly with the state machine when you need options such as WithTransitionReason, WithTransitionMetadata, or custom hooks:
auditSink := auth.ActivitySinkFunc(func(ctx context.Context, event auth.ActivityEvent) error {
log.Printf(
"user %s transitioned %s -> %s (reason=%v)",
event.UserID,
event.FromStatus,
event.ToStatus,
event.Metadata["reason"],
)
return nil
})
stateMachine := auth.NewUserStateMachine(
repoManager.Users(),
auth.WithStateMachineActivitySink(auditSink),
)
actor := auth.ActorRef{ID: "admin-42", Type: "admin"}
updated, err := stateMachine.Transition(
ctx,
actor,
user,
auth.UserStatusSuspended,
auth.WithTransitionReason("manual review"),
auth.WithTransitionMetadata(map[string]any{"ticket": "SEC-204"}),
)
if err != nil {
panic(err)
}
fmt.Println("new status:", updated.Status, "suspended at:", updated.SuspendedAt)See examples/extensions/extensions.go for an end-to-end sample that persists activity rows and decorates claims based on tenant context.
Transition hooks bubble failures through a configurable handler. By default go-auth panics with a detailed message (great for development). In production override this behavior with auth.WithStateMachineHookErrorHandler to convert hook failures into domain errors or alerts:
handler := func(ctx context.Context, phase auth.TransitionHookPhase, err error, tc auth.TransitionContext) error {
log.Printf("hook stage=%s user=%s error=%v", phase, tc.User.ID, err)
return fmt.Errorf("policy hook failed: %w", err)
}
stateMachine := auth.NewUserStateMachine(
repoManager.Users(),
auth.WithStateMachineHookErrorHandler(handler),
)ActivitySink is a small interface used across lifecycle transitions, login/impersonation flows, and password reset handlers:
ActivityEvent.EventTypedistinguishes lifecycle (user.status.changed), login, impersonation, and password reset actions.ActorRefidentifies who triggered the change (admin dashboard, API, system job).Metadatais an open map for reasons, ticket numbers, IP addresses, etc.- Failures are logged; auth flows continue unless you wrap the sink with your own retry/alerting logic.
Configure sinks wherever lifecycle events occur:
auditSink := auth.ActivitySinkFunc(func(ctx context.Context, event auth.ActivityEvent) error {
log.Printf("activity event=%s user=%s actor=%s", event.EventType, event.UserID, event.Actor.Type)
return nil
})
stateMachine := auth.NewUserStateMachine(users,
auth.WithStateMachineActivitySink(auditSink),
)
auther := auth.NewAuthenticator(provider, cfg).
WithActivitySink(auditSink)Use activitymap.Normalize when you need to forward auth.ActivityEvent into
another activity record contract without re implementing translation logic:
import (
"context"
"time"
auth "github.com/goliatone/go-auth"
"github.com/goliatone/go-auth/activitymap"
)
type ExternalRecord struct {
ActorID string
Verb string
ObjectType string
ObjectID string
Channel string
Metadata map[string]any
OccurredAt time.Time
}
sink := auth.ActivitySinkFunc(func(ctx context.Context, event auth.ActivityEvent) error {
normalized := activitymap.Normalize(
event,
activitymap.WithDefaultChannel("auth"),
activitymap.WithDefaultObjectType("user"),
activitymap.WithObjectIDResolver(func(e auth.ActivityEvent) string {
if v, ok := e.Metadata["password_reset_id"].(string); ok {
return v
}
return e.UserID
}),
)
record := ExternalRecord{
ActorID: normalized.ActorID,
Verb: normalized.Verb,
ObjectType: normalized.ObjectType,
ObjectID: normalized.ObjectID,
Channel: normalized.Channel,
Metadata: normalized.Metadata,
OccurredAt: normalized.OccurredAt,
}
_ = record
return nil
})Defaults:
channel:authobject_type:userobject_id:event.UserIDactor_id:event.Actor.ID -> event.UserID -> "system"
For ordinary events, normalized metadata is shallow-cloned from event.Metadata
and enriched with:
actor_type(fromevent.Actor.Type, when available)from_status/to_statusfor lifecycle transitions
Provider lifecycle/session events use restricted projections and identity fingerprints. Ordinary metadata can still contain submitted identifiers and provider errors; apply host disclosure/retention policy before persistence. See activity mapping for adapter guidance.
The same sink can forward events to a SQL table, queue, or logging pipeline. Refer to examples/extensions/extensions.go for a Postgres-based implementation and batching hints.
Use Auther.WithClaimsDecorator to enrich JWTs with tenant metadata or derived resource roles before they are signed. Decorators receive the pending JWTClaims and may only mutate extension fields such as Resources, Metadata, or additional custom payload that your product documents. Core JWT claims (sub, uid, iss, aud, iat, exp) are guarded and any attempt to edit them aborts token generation.
decorator := auth.ClaimsDecoratorFunc(func(ctx context.Context, identity auth.Identity, claims *auth.JWTClaims) error {
if claims.Metadata == nil {
claims.Metadata = map[string]any{}
}
claims.Metadata["tenant_id"] = lookupTenant(identity.ID())
if claims.Resources == nil {
claims.Resources = map[string]string{}
}
claims.Resources["team:"+identity.ID()] = "editor"
return nil
})
auther := auth.NewAuthenticator(provider, cfg).
WithClaimsDecorator(decorator)If the decorator returns an error the token flow stops, the failure is logged, and no JWT is issued. Coordinate decorations with your ActivitySink so downstream services can reconcile claims with lifecycle transitions. Additional wiring tips and a multi-tenant example live in examples/extensions/extensions.go.
go-auth minimizes oversized JWT metadata by default to reduce cookie bloat and
HTTP 431 Request Header Fields Too Large failures.
- Default claim minimization removes metadata keys (case-insensitive,
-normalized to_):permissions,permission_list,permissions_listscopes,scope_list,scopes_list
- Top-level structured claims are preserved:
role,res, and top-levelscopes
Token size guardrails apply to all signed JWTs:
- Warning threshold:
2048bytes (default) - Hard limit:
4096bytes (default, returnsErrTokenTooLarge)
You can override thresholds without changing interfaces:
auther := auth.NewAuthenticator(provider, cfg).
WithTokenSizeGuardrails(3072, 6144)Equivalent token-service options:
tokenService := auth.NewTokenService(
signingKey,
tokenExpiration,
issuer,
audience,
logger,
auth.WithTokenSizeGuardrails(2048, 4096),
)Migration note: applications that previously read permissions/scopes from
JWT metadata should move permission expansion to resolver-based checks
(CachedPermissionsResolver + request-scoped cache) and keep JWT payloads
minimal.
The Authenticator provides access to its underlying TokenService for advanced use cases:
// Access the token service directly
tokenService := authenticator.TokenService()
// Generate tokens manually with custom resource roles
resourceRoles := map[string]string{
"project:123": "admin",
"files:uploads": "owner",
}
token, err := tokenService.Generate(identity, resourceRoles)
// Validate tokens manually
claims, err := tokenService.Validate(tokenString)Use MintScopedToken to issue short lived tokens with optional scopes and TTL overrides (useful for debug or session scoped access). The helper reuses defaults from the built-in token service when available, and returns the computed expiration alongside the token.
tokenService := authenticator.TokenService()
opts := auth.ScopedTokenOptions{
TTL: 15 * time.Minute, // overrides default expiration
Scopes: []string{"debug.view"}, // stored in the "scopes" claim
}
token, expiresAt, err := auth.MintScopedToken(tokenService, identity, nil, opts)MintScopedToken keeps top-level JWTClaims.Scopes intact. Only metadata
scope arrays are minimized by default on token signing.
Use CachedPermissionsResolver to cache expensive permission resolution across requests. The resolver keeps request fanout under control with singleflight and stores permission sets through a pluggable PermissionCacheStore.
baseResolver := func(ctx context.Context) ([]string, error) {
// load effective permissions (db, role registry, policy engine, etc.)
return []string{"admin.translations.export"}, nil
}
resolver := auth.NewCachedPermissionsResolver(auth.CachedPermissionsResolverConfig{
Resolver: baseResolver,
KeyFunc: auth.DefaultPermissionsCacheKeyFromContext,
TTL: 2 * time.Minute,
// Store omitted -> defaults to in-memory implementation
// CacheErrorMode omitted -> defaults to fail_open
})
resolvePermissions := resolver.ResolverFunc()Request-scope dedupe helper:
ctx := auth.WithResolvedPermissionsCache(req.Context())
permissions, err := resolvePermissions(ctx) // repeated calls in this request resolve onceStrict-mode resolver validation:
if err := auth.ValidateResolverConfigured(true, resolvePermissions); err != nil {
return err
}
// or panic-fast for startup wiring:
auth.MustValidateResolverConfigured(true, resolvePermissions)PermissionCacheStore contract:
type PermissionCacheStore interface {
Get(ctx context.Context, key string) (permissions []string, ok bool, err error)
Set(ctx context.Context, key string, permissions []string, ttl time.Duration) error
Delete(ctx context.Context, key string) error
}Optional purge capability:
type PurgeablePermissionCacheStore interface {
PurgeExpired(ctx context.Context) (purged int, err error)
}If you use a generic cache package (for example go-cache), create a thin adapter that satisfies PermissionCacheStore:
type PermissionStoreAdapter struct {
cache cache.Cache[string, []string]
}
func (s *PermissionStoreAdapter) Get(ctx context.Context, key string) ([]string, bool, error) {
return s.cache.Get(ctx, key)
}
func (s *PermissionStoreAdapter) Set(ctx context.Context, key string, permissions []string, ttl time.Duration) error {
return s.cache.Set(ctx, key, permissions, ttl)
}
func (s *PermissionStoreAdapter) Delete(ctx context.Context, key string) error {
return s.cache.Delete(ctx, key)
}Then wire it into the resolver:
resolver := auth.NewCachedPermissionsResolver(auth.CachedPermissionsResolverConfig{
Resolver: baseResolver,
KeyFunc: auth.DefaultPermissionsCacheKeyFromContext,
Store: &PermissionStoreAdapter{cache: sharedCache},
TTL: 2 * time.Minute,
CacheErrorMode: auth.PermissionCacheErrorModeFailOpen, // or FailClosed
})Manual cache operations:
_ = resolver.Invalidate(ctx, cacheKey)
purged, err := resolver.PurgeExpired(ctx) // returns 0,nil if store is not purgeable
_ = purged
_ = errNotes:
DefaultPermissionsCacheKeyFromContextincludes permission affecting discriminators (version, token id, scopes, impersonation, session). If no discriminator is available, cross request caching is bypassed.PermissionCacheErrorModeFailOpen(default) keeps authz available when cache operations fail.PermissionCacheErrorModeFailClosedpropagates cache errors immediately.- Runtime counters are available through
resolver.Stats(). SetPermissionsVersionMetadata/PermissionsVersionFromContextkeep cache keys stable across permission-set updates.
Use the TokenService with JWT middleware for custom authentication flows:
// Create TokenServiceAdapter for jwtware middleware
tokenValidator := auth.NewTokenServiceAdapter(authenticator.TokenService())
// Configure JWT middleware
jwtConfig := jwtware.Config{
SigningKey: jwtware.SigningKey{
Key: []byte(config.GetSigningKey()),
JWTAlg: config.GetSigningMethod(),
},
TokenValidator: tokenValidator,
ContextKey: "auth",
}
middleware := jwtware.New(jwtConfig)The library defines a hierarchical role system:
guest- Read-only accessmember- Read and edit accessadmin- Read, edit, and create accessowner- Full access including delete
Beyond global user roles, the library supports resource-specific permissions:
// Example resource roles map
resourceRoles := map[string]string{
"project:123": "admin", // Admin of project 123
"admin:dashboard": "member", // Member access to admin dashboard
"files:uploads": "owner", // Owner of file uploads
}The library generates structured JWT claims:
{
"sub": "user-id",
"uid": "user-id",
"role": "admin",
"res": {
"project:123": "admin",
"admin:dashboard": "member"
},
"scopes": ["debug.view", "debug.repl"],
"iss": "your-app",
"aud": ["your-audience"],
"iat": 1234567890,
"exp": 1234654290,
"jti": "token-id"
}The library requires two database tables. Note: migrations use TEXT IDs in
Postgres/SQLite, while Go models cast to uuid.UUID. This is intentional for
now; IDs are stored as text and parsed/validated in the app. We may move to
native UUID columns in a future migration.
CREATE TABLE users (
id TEXT NOT NULL PRIMARY KEY,
user_role TEXT NOT NULL DEFAULT 'guest' CHECK (
user_role IN ('guest', 'member', 'admin', 'owner')
),
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
username TEXT NOT NULL,
profile_picture TEXT,
email TEXT NOT NULL UNIQUE,
phone_number TEXT,
password_hash TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
is_email_verified BOOLEAN DEFAULT FALSE,
login_attempts INTEGER DEFAULT 0,
login_attempt_at TIMESTAMP,
loggedin_at TIMESTAMP,
status TEXT NOT NULL DEFAULT 'active' CHECK (
status IN ('pending', 'active', 'suspended', 'disabled', 'archived')
),
suspended_at TIMESTAMP,
external_id TEXT,
external_id_provider TEXT,
reseted_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP,
deleted_at TIMESTAMP
);CREATE TABLE password_reset (
id TEXT NOT NULL PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
email TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'unknown' CHECK (
status IN ('unknown', 'requested', 'expired', 'changed')
),
reseted_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP,
deleted_at TIMESTAMP
);Social login adds a social_accounts table for linked provider identities.
Migrations ship in data/sql/migrations and data/sql/migrations/sqlite.
Use auth.GetAuthExtrasMigrationsFS() for segmented registration (or
auth.GetMigrationsFS() for the full tree).
CREATE TABLE social_accounts (
id TEXT NOT NULL PRIMARY KEY,
user_id TEXT NOT NULL,
provider TEXT NOT NULL,
provider_user_id TEXT NOT NULL,
email TEXT,
name TEXT,
username TEXT,
avatar_url TEXT,
access_token TEXT,
refresh_token TEXT,
token_expires_at TIMESTAMP NULL,
profile_data JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
CONSTRAINT uq_social_accounts_provider_id UNIQUE (provider, provider_user_id),
CONSTRAINT uq_social_accounts_user_provider UNIQUE (user_id, provider)
);
CREATE INDEX idx_social_accounts_user_id ON social_accounts(user_id);
CREATE INDEX idx_social_accounts_provider ON social_accounts(provider, provider_user_id);SSO and Auth0 sync use the provider-neutral user_identifiers mapping table.
The canonical lookup key is (provider, identifier), where identifier is the
provider subject. Optional external ID columns on users are legacy enrichment,
not the primary SSO lookup path. Migrations ship in data/sql/migrations and
data/sql/migrations/sqlite:
20240701090000_auth0_identifiers.up.sql20240701090000_auth0_identifiers.down.sql
See docs/AUTH0.md for schema details and sync wiring.
go-auth exposes segmented migration filesystems:
auth.GetCoreMigrationsFS()for core auth schema (users,password_reset,user_status).auth.GetAuthExtrasMigrationsFS()for optional extras (social_accounts,user_identifiers,users.external_id*, OIDC state, provider sessions/tokens/fences, and lifecycle operations).auth.GetMigrationsFS()for the full migration tree.
Register core first, then extras when both are used.
Segmented assets are now directory-packaged under
data/sql/migration_tracks/{core,auth_extras} to avoid filename-pattern embeds.
The library uses a configuration interface that you can implement:
type Config interface {
GetSigningKey() string
GetTokenExpiration() int
GetExtendedTokenDuration() int
GetIssuer() string
GetAudience() []string
GetContextKey() string
GetTokenLookup() string
GetAuthScheme() string
GetRejectedRouteKey() string
GetRejectedRouteDefault() string
}- User submits registration form
- Password is hashed using bcrypt
- User record is created in database
- Optional email verification can be triggered
- User provides credentials
- Identity provider verifies password
- Resource role provider fetches permissions (if configured)
- JWT token is generated with structured claims
- Token is returned to client
- User requests password reset via email
- Reset token is generated and stored
- Email with reset link is sent
- User follows link to reset password
- New password is hashed and updated
The library provides HTTP middleware for route protection:
// Protect routes requiring authentication
httpAuth, err := auth.NewHTTPAuthenticator(authenticator, config)
if err != nil {
log.Fatal(err)
}
protectedRoute := httpAuth.ProtectedRoute(config, errorHandler)
router.Use("/api/", protectedRoute)
// Custom authorization logic
func requireAdminRole(c router.Context) error {
session, err := auth.GetRouterSession(c, config.GetContextKey())
if err != nil {
return c.Status(401).SendString("Unauthorized")
}
if !session.IsAtLeast(auth.RoleAdmin) {
return c.Status(403).SendString("Forbidden")
}
return c.Next()
}auth.NewWSTokenValidator adapts a TokenValidator to go-router's
WSTokenValidator. Auther.NewWSAuthMiddleware returns the high-level
router.WebSocketMiddleware shape (WSHandler to WSHandler). Register the
wrapped handler through router.NewWSHandler; there is no router.WS method
or *router.WSContext type.
func registerChat[T any](routes router.Router[T], authenticator *auth.Auther) {
protected := authenticator.NewWSAuthMiddleware()
routes.Get("/chat", router.NewWSHandler(protected(
func(ctx context.Context, client router.WSClient) error {
claims, ok := auth.WSAuthClaimsFromContext(ctx)
if !ok || !claims.CanRead("chat:messages") {
return client.Close(1008, "Unauthorized")
}
return client.SendJSON(map[string]string{"user_id": claims.UserID()})
},
)))
}For custom extraction/failure handling, pass router.WSAuthConfig:
wsConfig := router.WSAuthConfig{
TokenExtractor: func(ctx context.Context, client router.WSClient) (string, error) {
token := client.Conn().Header("X-Auth-Token")
if token == "" { return "", errors.New("missing auth token") }
return token, nil
},
OnAuthFailure: func(ctx context.Context, client router.WSClient, err error) error {
return client.Close(1008, "Authentication failed")
},
}
protected := authenticator.NewWSAuthMiddleware(wsConfig)Default extraction checks token query parameters, then the Authorization header;
cookie extraction requires EnableTokenCookie. Hosts choose the transport and
avoid exposing token-bearing URLs in logs. Authentication here runs in the
high-level connection handler. Use package-managed HTTP/pre-upgrade admission
when a rejection must carry an HTTP status before the upgrade.
For a custom validator, return claims through
auth.NewWSTokenValidator(customTokenValidator) rather than constructing
WSAuthClaimsAdapter directly: its wrapped claims are private. Use the
low-level router.WebSocketContext API only when the application owns the
read/write loop. See websocket_adapter.go and go-router's WebSocket guides.
The library includes command handlers for common operations. resetDelivery
below is a host-provided auth.PasswordResetDelivery that sends the opaque
credential through a trusted channel; reset credentials are not returned in
command responses. Configure feature gates through WithFeatureGate when used.
// Register user
registerUser := auth.NewRegisterUserHandler(repositoryManager)
err := registerUser.Execute(ctx, auth.RegisterUserMessage{
FirstName: "John",
LastName: "Doe",
Email: "john@example.com",
Password: "securepassword",
})
// Initialize password reset
initReset := auth.NewInitializePasswordResetHandler(repositoryManager).WithDelivery(resetDelivery)
err := initReset.Execute(ctx, auth.InitializePasswordResetMessage{
Email: "user@example.com",
Stage: "show-reset",
})See docs/GUIDE_DEVELOPMENT_CICD.md for the
multi-module formatting, test, lint, security, CI, and release-validation
workflow.
Run the complete local browser example from its module:
cd examples
./taskfile dev:runThe task regenerates Go configuration, installs the pinned frontend build
dependencies when needed, rebuilds public/css/main.css from every template
under views/, builds the Go binary, and starts it on
http://127.0.0.1:8572.