Guides
Server-Side Plugins
Extend Authula functionality with custom plugins via Go.
Overview
Authula's plugin system lets you extend the authentication framework with custom functionality. Each plugin implements a base interface and can optionally add routes, migrations, middleware, hooks, or auth methods.
Core Architecture
Plugin Interface
Every plugin must implement this:
type Plugin interface {
Metadata() PluginMetadata
Config() any
Init(ctx *PluginContext) error
Close() error
}| Method | Purpose |
|---|---|
Metadata() | Returns plugin ID, version, description |
Config() | Returns the typed config struct |
Init(ctx) | Initialize with access to DB, logger, event bus, service registry |
Close() | Clean up resources on shutdown |
Plugin Context
type PluginContext struct {
DB bun.IDB
Logger Logger
EventBus EventBus
ServiceRegistry ServiceRegistry
GetConfig func() *Config
}Service Registry
Plugins communicate by registering and retrieving services:
type ServiceRegistry interface {
Register(name string, service any)
Get(name string) any
}Optional Capabilities
Plugins implement these optional interfaces to add functionality:
PluginWithMigrations — Database Migrations
type PluginWithMigrations interface {
Migrations(provider string) []migrations.Migration
DependsOn() []string
}func (p *MyPlugin) Migrations(provider string) []migrations.Migration {
return migrations.ForProvider(provider, migrations.ProviderVariants{
"sqlite": func() []migrations.Migration { return []migrations.Migration{mySQLite()} },
"postgres": func() []migrations.Migration { return []migrations.Migration{myPostgres()} },
})
}
func (p *MyPlugin) DependsOn() []string { return nil }
func mySQLite() migrations.Migration {
return migrations.Migration{
Version: "20250101000000_create_my_table",
Up: func(ctx context.Context, tx bun.Tx) error {
return migrations.ExecStatements(ctx, tx,
`CREATE TABLE IF NOT EXISTS my_table (
id TEXT NOT NULL PRIMARY KEY
);`,
)
},
Down: func(ctx context.Context, tx bun.Tx) error {
return migrations.ExecStatements(
ctx,
tx,
`DROP TABLE IF EXISTS my_table;`
)
},
}
}PluginWithRoutes — HTTP Routes
type PluginWithRoutes interface {
Routes() []Route
}func (p *MyPlugin) Routes() []models.Route {
return []models.Route{
{
Method: "GET",
Path: "/api/my-plugin/status",
Handler: http.HandlerFunc(p.handleStatus),
},
}
}PluginWithMiddleware — Global Middleware
type PluginWithMiddleware interface {
Middleware() []func(http.Handler) http.Handler
}AuthMethodProvider — Authentication Middleware
type AuthMethodProvider interface {
AuthMiddleware() func(http.Handler) http.Handler
OptionalAuthMiddleware() func(http.Handler) http.Handler
}AuthMiddleware()— rejects unauthenticated requestsOptionalAuthMiddleware()— authenticates if credentials present, allows anonymous
PluginWithHooks — Request Lifecycle Hooks
type PluginWithHooks interface {
Hooks() []Hook
}Four hook stages:
HookOnRequest // Very start of every request
HookBefore // Before route matching and handling
HookAfter // After route handling, before response sent
HookResponse // After response writtenHook struct:
type Hook struct {
Stage HookStage
PluginID string // Optional — filters by route metadata["plugins"]
Matcher HookMatcher // Optional — filters by request context
Handler HookHandler // The hook implementation
Order int // Execution order (local to PluginID)
Async bool // Side-effects only — never auth/security
}Execution rules:
- Hooks without
PluginIDrun for all routes; withPluginIDonly if listed in route metadata - Hooks sorted by
PluginID(grouping), then byOrderwithin each group Matcherreturningfalseskips the hookHandlerreturning an error is logged but doesn't stop other hooks- Setting
reqCtx.Handled = truestops subsequent hooks at that stage - Async hooks run in background goroutines for side-effects only (logging, analytics, events, webhooks). Never use for auth, CSRF, rate-limiting, or security.
func (p *MyPlugin) Hooks() []models.Hook {
return []models.Hook{
{
Stage: models.HookBefore,
PluginID: "my_plugin",
Matcher: func(rc *models.RequestContext) bool { return strings.HasPrefix(rc.Path, "/api/secure") },
Handler: p.validateToken,
Order: 10,
},
}
}RequestContext
Available to all hooks and handlers:
reqCtx.Request // *http.Request
reqCtx.ResponseWriter // http.ResponseWriter
reqCtx.Path // string
reqCtx.Method // string
reqCtx.Headers // http.Header
reqCtx.Actor // *Actor — resolved identity
reqCtx.ClientIP // string
reqCtx.Values // map[string]any — shared key-value store
reqCtx.Route // *Route — matched route with metadata
reqCtx.Handled // bool — short-circuit flagHelper methods:
reqCtx.SetActorInContext(actor *Actor) // Sets actor in both RequestContext and Go context
reqCtx.SetResponse(status int, headers http.Header, body []byte) // Override response
reqCtx.SetJSONResponse(status int, payload any) // Override with JSONCreating a Custom Plugin
1. Define the plugin
package myplugin
import (
"net/http"
"github.com/Authula/authula/internal/util"
"github.com/Authula/authula/models"
"github.com/Authula/authula/services"
)
type MyPlugin struct {
config MyPluginConfig
logger models.Logger
ctx *models.PluginContext
userService services.UserService
}
type MyPluginConfig struct {
Enabled bool `json:"enabled" toml:"enabled"`
SomeField string `json:"some_field" toml:"some_field"`
}2. Implement the base interface
func New(config MyPluginConfig) *MyPlugin {
return &MyPlugin{config: config}
}
func (p *MyPlugin) Metadata() models.PluginMetadata {
return models.PluginMetadata{
ID: "my_plugin",
Version: "1.0.0",
Description: "Custom functionality",
}
}
func (p *MyPlugin) Config() any { return p.config }
func (p *MyPlugin) Init(ctx *models.PluginContext) error {
p.ctx = ctx
p.logger = ctx.Logger
if err := util.LoadPluginConfig(ctx.GetConfig(), p.Metadata().ID, &p.config); err != nil {
return err
}
userSvc, ok := ctx.ServiceRegistry.Get(models.ServiceUser.String()).(services.UserService)
if !ok {
return fmt.Errorf("user service not available")
}
p.userService = userSvc
return nil
}
func (p *MyPlugin) Close() error { return nil }3. Add routes (optional)
func (p *MyPlugin) Routes() []models.Route {
return []models.Route{
{
Method: "GET",
Path: "/api/my-plugin/info",
Handler: http.HandlerFunc(p.handleInfo),
},
}
}
func (p *MyPlugin) handleInfo(w http.ResponseWriter, r *http.Request) {
util.JSONResponse(w, http.StatusOK, map[string]string{
"plugin_id": p.Metadata().ID,
})
}4. Register with Authula
config := authulaconfig.NewConfig()
auth := authula.New(&authula.AuthConfig{
Config: config,
Plugins: []models.Plugin{
myplugin.New(myplugin.MyPluginConfig{
SomeField: "your-value",
}),
},
})Plugins can also be loaded automatically from a config file — see the configuration docs for details.
Configuration
Example TOML:
[plugins.session]
enabled = true
[plugins.oauth2]
enabled = true
[plugins.ratelimit]
enabled = true
window = "1m"
max = 100
[plugins.my_plugin]
enabled = true
some_field = "your-value"Best Practices
- Validate config in
Init— return an error if invalid - Clean up resources in
Close - Use the provided logger, not external ones
- Use
Async: trueonly for side-effects (logging, analytics, events) - Never make auth validation, CSRF, or rate-limiting hooks async
- Plugin IDs should be lowercase with underscores
- Use
reqCtx.SetActorInContext()to set the authenticated actor - Use
util.JSONResponse()for consistent API responses
Complete Example: Request Logger Plugin
package logger
import (
"fmt"
"time"
"github.com/Authula/authula/models"
)
type LoggerPlugin struct {
config LoggerPluginConfig
logger models.Logger
ctx *models.PluginContext
}
type LoggerPluginConfig struct {
Enabled bool `json:"enabled" toml:"enabled"`
LogRequests bool `json:"log_requests" toml:"log_requests"`
LogLevel string `json:"log_level" toml:"log_level"`
}
func New(config LoggerPluginConfig) *LoggerPlugin {
return &LoggerPlugin{config: config}
}
func (p *LoggerPlugin) Metadata() models.PluginMetadata {
return models.PluginMetadata{
ID: "logger",
Version: "1.0.0",
Description: "Logs incoming requests with timing",
}
}
func (p *LoggerPlugin) Config() any { return p.config }
func (p *LoggerPlugin) Init(ctx *models.PluginContext) error {
p.ctx = ctx
p.logger = ctx.Logger
return nil
}
func (p *LoggerPlugin) Close() error { return nil }
func (p *LoggerPlugin) Hooks() []models.Hook {
return []models.Hook{
{
Stage: models.HookOnRequest,
Matcher: func(rc *models.RequestContext) bool { return p.config.LogRequests },
Handler: p.logStart,
Order: 10,
},
{
Stage: models.HookAfter,
Matcher: func(rc *models.RequestContext) bool { return p.config.LogRequests },
Handler: p.logComplete,
Order: 20,
Async: true,
},
}
}
func (p *LoggerPlugin) logStart(rc *models.RequestContext) error {
rc.Values["start_time"] = time.Now()
if p.config.LogLevel == "debug" {
p.logger.Debug(fmt.Sprintf("→ %s %s from %s", rc.Method, rc.Path, rc.ClientIP))
}
return nil
}
func (p *LoggerPlugin) logComplete(rc *models.RequestContext) error {
start, ok := rc.Values["start_time"].(time.Time)
if !ok {
return nil
}
userID := ""
if rc.Actor != nil && rc.Actor.Type == models.ActorUser {
userID = rc.Actor.ID
}
elapsed := time.Since(start)
msg := fmt.Sprintf("← %s %s → %d (%v)", rc.Method, rc.Path, rc.ResponseStatus, elapsed)
if userID != "" {
msg += fmt.Sprintf(" user=%s", userID)
}
if rc.ResponseStatus >= 400 {
p.logger.Error(msg)
} else {
p.logger.Info(msg)
}
return nil
}