Authula
Integrations

Hooks

Configure hooks to customise the HTTP request/response lifecycle in Authula.

Overview

The hooks system in Authula provides a flexible and extensible mechanism for plugins to intercept and modify the request lifecycle. Hooks allow you to execute custom logic at specific points during request processing without tightly coupling your code to the router.

The system enables powerful capabilities such as authentication, authorization, rate limiting, logging, and custom business logic integration. Each hook operates within the request context and can modify request state, share data with other hooks, or short-circuit the request flow.

Hook Stages

Hooks execute at five distinct stages in the request lifecycle:

1. HookOnRequest

  • Executes for every incoming request at the very start
  • Ideal for global logging, metrics collection, or request preprocessing
  • Runs before route matching and handling

2. HookBefore

  • Executes before route matching and handling
  • Perfect for authentication, authorization, and request validation
  • Common use: checking session tokens, validating permissions

3. HookAfter

  • Executes after route handling but before the response is sent
  • Useful for response modification, post-processing, or cleanup
  • Good for adding response headers or transforming output

4. HookOnResponse

  • Executes after route handling and HookAfter, once the response has been produced
  • Only runs on the normal request path; it is skipped if an earlier hook short-circuits
  • Suitable for analytics, audit logging, or fire-and-forget operations
  • Should not be relied on for cleanup that must run on every request

5. HookOnComplete

  • Executes exactly once for every request, after all other hook stages
  • Also runs after an early short-circuit, immediately before the response is flushed
  • Route handlers never observe this stage
  • Cannot short-circuit the request or change routing
  • Always runs synchronously; Async is ignored for this stage
  • Runs on bypass paths such as OPTIONS preflight and disabled-path 404 responses
  • Use it for finalization: ending telemetry spans, recording metrics, flushing per-request buffers, or releasing resources

HookOnResponse only runs on the normal path. HookOnComplete is the guaranteed finalization stage: it runs even when an earlier hook sets Handled, so it is the right place for cleanup that must happen on every exit path.

Execution order

Normal request:
HookOnRequest → HookBefore → route handler → HookAfter → HookOnResponse → HookOnComplete → flush

Short-circuited request:
HookOnRequest → HookBefore → HookOnComplete → flush

Configuration Options

Synchronization Types

Hooks can be either synchronous or asynchronous:

// Synchronous hook (default)
Hook{
    Async: false, // Blocks the request flow until completion
    Handler: myHandler,
}

// Asynchronous hook
Hook{
    Async: true, // Runs in a background goroutine
    Handler: myAsyncHandler,
}
  • Synchronous: Blocks the request flow and waits for completion. Use for authentication, validation, and critical security checks.
  • Asynchronous: Runs in a background goroutine without blocking the response. Ideal for logging, analytics, and side effects.

Error Handling Modes

Configure how the router handles hook errors:

opts := &RouterOptions{
    HookErrorMode: HookErrorModeContinue, // Default: log errors, continue execution
}
  • HookErrorModeContinue (default): Logs errors but continues to the next hook
  • HookErrorModeFailFast: Logs error and stops remaining hooks in the current stage
  • HookErrorModeSilent: Silently ignores errors without logging

Async Hook Timeout

Control how long async hooks can run before being cancelled:

opts := &RouterOptions{
    AsyncHookTimeout: 30 * time.Second, // Default timeout
}

Hook Structure

Each hook is defined with the following properties:

type Hook struct {
    Stage       HookStage   // When to execute (HookOnRequest, HookBefore, etc.)
    PluginID    string      // Optional: limits execution to specific routes
    Matcher     HookMatcher // Optional: conditional execution based on request
    Handler     HookHandler // The function that executes the hook
    Order       int         // Execution order within the same stage
    Async       bool        // Whether to run asynchronously (ignored for HookOnComplete)
    RunOnBypass bool        // Also run HookOnRequest hooks on bypass paths
}

Properties Explained

  • Stage: Determines when the hook executes in the request lifecycle
  • PluginID: If set, hook only executes if the route's metadata includes this plugin ID
  • Matcher: A function that returns true/false to conditionally execute the hook
  • Handler: The actual function that performs the hook's logic
  • Order: Controls execution order among hooks in the same stage (lower numbers first)
  • Async: Whether the hook runs synchronously or asynchronously; ignored for HookOnComplete, which always runs synchronously
  • RunOnBypass: When true, a HookOnRequest hook also runs on requests that never enter the normal lifecycle, such as OPTIONS preflight and disabled-path 404 responses. These hooks must not alter routing or short-circuit. HookOnComplete runs on those paths automatically, regardless of this field.

Practical Examples

Basic Authentication Hook

// Simple authentication hook that checks for a valid session
authHook := models.Hook{
    Stage: models.HookBefore,
    Order: 0,
    Handler: func(ctx *models.RequestContext) error {
        // Check if user is authenticated
        sessionToken := ctx.Headers.Get("Authorization")
        if sessionToken == "" {
            ctx.SetResponse(401, nil, []byte(`{"error": "Unauthorized"}`))
            ctx.Handled = true
            return nil
        }

        // Validate session and set user info
        userID, err := validateSession(sessionToken)
        if err != nil {
            ctx.SetResponse(401, nil, []byte(`{"error": "Invalid session"}`))
            ctx.Handled = true
            return nil
        }

        // Store user ID in context for later use
        ctx.SetUserIDInContext(userID)
        return nil
    },
}

Conditional Hook with Matcher

// Only execute hook for admin routes
adminOnlyHook := models.Hook{
    Stage: models.HookBefore,
    Matcher: func(reqCtx *models.RequestContext) bool {
        // Only run for paths starting with /admin
        return strings.HasPrefix(reqCtx.Path, "/admin")
    },
    Handler: func(reqCtx *models.RequestContext) error {
        if reqCtx.Actor == nil || reqCtx.Actor.Type != models.ActorUser {
            reqCtx.SetJSONResponse(http.StatusUnauthorized, map[string]any{"message": "Authentication required"})
            reqCtx.Handled = true
            return nil
        }

        userID := reqCtx.Actor.ID

        // Check if user has admin role
        isAdmin, err := hasRole(userID, "admin")
        if err != nil || !isAdmin {
            reqCtx.SetJSONResponse(http.StatusForbidden, map[string]any{"message": "Admin access required"})
            reqCtx.Handled = true
            return nil
        }

        return nil
    },
}

Data Sharing Between Hooks

// First hook sets data in the Values map
setUserDataHook := models.Hook{
    Stage: models.HookBefore,
    Order: 0,
    Handler: func(reqCtx *models.RequestContext) error {
        if reqCtx.Actor == nil || reqCtx.Actor.Type != models.ActorUser {
            return nil // No user, skip
        }

        userID := reqCtx.Actor.ID
        if userID != "" {
            // Fetch user profile and store in Values for other hooks
            userProfile, err := getUserProfile(userID)
            if err != nil {
                return err
            }
            reqCtx.Values["user_profile"] = userProfile
        }

        return nil
    },
}

// Second hook reads the shared data
auditLogHook := models.Hook{
    Stage: models.HookAfter,
    Order: 1, // Must run after setUserDataHook
    Handler: func(reqCtx *models.RequestContext) error {
        if profile, exists := reqCtx.Values["user_profile"]; exists {
            // Log the action with user information
            logAuditEvent(profile.(UserProfile), reqCtx.Path, reqCtx.Method)
        }
        return nil
    },
}

Asynchronous Analytics Hook

// Fire-and-forget analytics hook
analyticsHook := models.Hook{
    Stage: models.HookOnResponse,
    Async: true,
    Handler: func(reqCtx *models.RequestContext) error {
        if reqCtx.Actor == nil || reqCtx.Actor.Type != models.ActorUser {
            return nil // No user, skip analytics
        }

        // Track page views, user actions, etc.
        trackEvent("page_view", map[string]interface{}{
            "path":     reqCtx.Path,
            "method":   reqCtx.Method,
            "clientIP": reqCtx.ClientIP,
            "userID":   reqCtx.Actor.ID,
        })

        return nil
    },
}

Finalization with HookOnComplete

// Pair a start hook with a completion hook to measure every request,
// including requests rejected by an earlier authentication hook.
func (p *MyPlugin) Hooks() []models.Hook {
    return []models.Hook{
        {
            Stage: models.HookOnRequest,
            Handler: func(reqCtx *models.RequestContext) error {
                reqCtx.Values["request_start"] = time.Now()
                return nil
            },
        },
        {
            Stage: models.HookOnComplete,
            Handler: func(reqCtx *models.RequestContext) error {
                start, ok := reqCtx.Values["request_start"].(time.Time)
                if !ok {
                    return nil
                }

                status := reqCtx.ResponseStatus
                if status == 0 {
                    status = http.StatusOK
                }

                recordRequestDuration(reqCtx.Method, reqCtx.Path, status, time.Since(start))
                return nil
            },
        },
    }
}

PluginID, Matcher, and Order still apply to HookOnComplete. The Async field is ignored, and completion hooks run on bypass paths automatically, so they do not need RunOnBypass.

Plugin-Specific Hook

// Hook that only runs for routes that include this plugin
rateLimitHook := models.Hook{
    Stage:    models.HookBefore,
    PluginID: "ratelimit",
    Order:    0,
    Handler: func(reqCtx *models.RequestContext) error {
        // Apply rate limiting only to routes that have "ratelimit" in their metadata
        if err := checkRateLimit(reqCtx.ClientIP); err != nil {
            reqCtx.SetJSONResponse(http.StatusTooManyRequests, map[string]any{"message": "Rate limit exceeded"})
            reqCtx.Handled = true
            return nil
        }
        return nil
    },
}

Best Practices

1. Order Matters

  • Use lower Order values for security-critical hooks (authentication, authorization)
  • Higher Order values for less critical operations
  • Remember that order is local to each plugin, so compare within the same PluginID

2. Handle Errors Gracefully

  • Always return appropriate errors from your handlers
  • Use ctx.Handled = true to short-circuit when returning an error response
  • Consider the error handling mode when designing your hooks

3. Use Asynchronous Hooks for Side Effects

  • Use Async: true for logging, analytics, notifications
  • Keep synchronous hooks for authentication, validation, and response modification
  • Be mindful of the timeout for async hooks
  • Do not use Async for HookOnComplete; completion hooks always run synchronously

4. Use HookOnComplete for Guaranteed Finalization

  • Use HookOnComplete for cleanup, telemetry, metrics, and buffer flushing that must happen on every request
  • Do not rely on HookAfter or HookOnResponse for cleanup; they are skipped when an earlier hook short-circuits
  • Keep completion hooks fast, since they run immediately before the response is flushed
  • Completion hooks cannot change routing or short-circuit the request

5. Leverage the Values Map

  • Use reqCtx.Values to share data between hooks
  • Store computed values that other hooks might need
  • Avoid storing sensitive information in Values

6. Use Matchers for Conditional Logic

  • Implement Matcher functions to conditionally execute hooks
  • This is more efficient than checking conditions inside the handler
  • Keep matchers lightweight to avoid performance overhead

7. Plugin Integration

  • Use PluginID to ensure hooks only run on relevant routes
  • Coordinate with route metadata to enable/disable hooks per endpoint
  • Follow the plugin architecture patterns for consistency

Use Cases

Authentication & Authorization

  • Session validation
  • JWT verification
  • Role-based access control
  • Permission checking

Security

  • Rate limiting
  • CSRF protection
  • Input validation
  • IP blacklisting

Monitoring & Analytics

  • Request logging
  • Performance metrics
  • User activity tracking
  • Error monitoring
  • Request finalization, including telemetry span completion and duration metrics

Business Logic

  • Custom validation rules
  • Data enrichment
  • Audit trails
  • Notification triggers

The hooks system provides a powerful and flexible foundation for extending Authula's functionality while maintaining clean separation of concerns between different aspects of your authentication and authorization logic.

NOTE: this documentation explains a general overview of the hooks system but bear in mind that Authula may already contain a lot of this functionality already as plugins so you may not need to implement the things that are mentioned under "use cases" in your own project etc. So take a look at the Plugins documentation next to see what is already available!

On this page