Auth

Audit Logging

Record authentication and authorization events through a pluggable audit logger.

Auth events go to an auth.AuditLogger. Nothing is recorded until you configure one, and everything that emits events treats a nil logger as "off".

type AuditLogger interface {
    LogAuthEvent(ctx context.Context, event AuditEvent)
    LogLoginAttempt(ctx context.Context, userID, result string, details map[string]any)
    LogAccessAttempt(ctx context.Context, userID, resource, action, result string, details map[string]any)
    LogRoleCheck(ctx context.Context, userID, role, result string, details map[string]any)
    LogPermissionCheck(ctx context.Context, userID, permission, result string, details map[string]any)
}

Log to slog

logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

provider := auth.NewJWTAuthProvider(jwt).
    WithAuditLogger(auth.NewSlogAuditLogger(logger))

app := ezz.New().Logger(logger).Auth(provider)

Passing nil to NewSlogAuditLogger uses slog.Default(). Events are written at info level under the message auth_event, with the user ID, action, resource, result, request ID, IP address, user agent, and any extra details as attributes.

auth.NewNoOpAuditLogger() discards everything, which is useful in tests or to disable auditing without changing the wiring.

What gets recorded today

EventEmitted byMethod
Token validation failureJWTAuthProvider.AuthenticateLogAccessAttempt with reason: invalid_token
GoTrue sign-in and sign-up, success or failureThe GoTrue proxy on /token and /signupLogLoginAttempt
Every other proxied GoTrue callThe GoTrue proxyLogAccessAttempt

The proxy details include the GoTrue path, the resolved client IP, the request ID, and the user agent. See GoTrue proxy.

Successful token validation is not logged, and role and permission decisions are not logged automatically. Call the logger yourself where you need those:

app.DELETE("/users/{id}", ezz.H(func(ctx *ezz.Context, req DeleteUserRequest) (any, error) {
    user := ctx.User().(auth.User)

    audit.LogAccessAttempt(ctx, user.ID(), "/users/"+req.ID, "DELETE", "success", map[string]any{
        "request_id": ctx.GetRequestID(),
    })
    return nil, store.Delete(ctx, req.ID)
})).Auth(ezz.Role("admin"))

Write your own logger

Implement the interface to send events to a database, a queue, or a SIEM. The event struct is what a full record looks like:

type AuditEvent struct {
    Timestamp time.Time
    UserID    string
    Action    string
    Resource  string
    Result    string
    Details   map[string]any
    RequestID string
    IPAddress string
    UserAgent string
}
type DBAuditLogger struct {
    events chan auth.AuditEvent
}

func NewDBAuditLogger(db *sql.DB) *DBAuditLogger {
    l := &DBAuditLogger{events: make(chan auth.AuditEvent, 1024)}
    go l.drain(db)
    return l
}

func (l *DBAuditLogger) LogAuthEvent(ctx context.Context, event auth.AuditEvent) {
    if event.Timestamp.IsZero() {
        event.Timestamp = time.Now()
    }
    select {
    case l.events <- event:
    default:
        // prefer dropping an event over blocking the request
    }
}

func (l *DBAuditLogger) LogLoginAttempt(ctx context.Context, userID, result string, details map[string]any) {
    l.LogAuthEvent(ctx, auth.AuditEvent{UserID: userID, Action: "login", Result: result, Details: details})
}

// LogAccessAttempt, LogRoleCheck, and LogPermissionCheck follow the same pattern

Two things to keep in mind when writing one:

  • Auditing happens on the request path. Buffer or batch anything that talks to the network, and never block the request on a slow sink.
  • The context you receive is the request context, so it is cancelled when the response finishes. Copy what you need before handing work to a goroutine.

Use the same logger for the proxy

GoTrueConfig takes the same interface, so proxy traffic and your own routes land in one stream:

audit := auth.NewSlogAuditLogger(logger)

gt := auth.NewGoTrue(auth.GoTrueConfig{
    InternalURL: "http://gotrue:9999",
    JWTSecret:   secret,
    AuditLogger: audit,
})

app := ezz.New().Auth(gt.AuthProvider().WithAuditLogger(audit))
gt.Mount(app, "/auth")
Audit records from the proxy carry an empty user ID, because the proxy does not decode the token it forwards. Correlate with the request ID, which is present on both the audit record and the X-Request-ID response header.

Next steps

Copyright © 2026