go-auth
Guides

Audit Logs

Turning on audit logging, what gets recorded, and the two admin endpoints for querying it.

Audit Logs

Audit logging is off by default. When enabled, significant activity is recorded as structured audit.Event rows in audit_log. Security-critical transactional paths — registration, successful login session issuance, password change/reset, session creation/revocation, account deletion, and role changes — write the record in the same transaction as the state change, so the record exists if and only if that change committed. Standalone observations such as a failed login have no state transaction and are inserted directly. When at least one external sink is configured, the same write adds one audit_outbox obligation for the event; a background dispatcher claims it and fans it out to the configured sink set with retries. With no external sink configured there is no delivery machinery at all.

Configuration

goauth.WithAudit(goauth.AuditConfig{
    Enabled:       true,                    // optional, default false
    FailureMode:   audit.FailureOpen,   // optional, default fail-open — or audit.FailureClosed
    RetentionDays: 0,                       // optional, default 0 — keep forever (0 = forever)
    Workers:       3,                        // optional, default 3 — outbox dispatcher workers (only when Sinks set)
    BatchSize:     50,                       // optional, default 50 — outbox claim/delivery batch size
    FlushInterval: 100 * time.Millisecond,   // optional, default 100ms — outbox dispatch poll interval
    MaxAttempts:   10,                       // optional, default 10 — delivery attempts before a row is dead-lettered
    ClaimLease:    10 * time.Minute,         // optional, default 10m — floor 1m
    OutboxMaxAge:  7 * 24 * time.Hour,       // optional, default 7d — pending-row age bound (negative disables)
    DeadLetterTTL: 7 * 24 * time.Hour,       // optional, default 7d — dead-letter evidence window (negative keeps forever)
    OutboxMaxRows: 100000,                   // optional, default 100000 — backlog emergency valve (negative = unbounded)
    Sinks:         []audit.EventSink{myKafkaSink}, // optional, default none
    // EnqueueFailureMode resolves fail-open vs fail-closed per event (nil = fail-open everywhere)
})
goauth.WithAuditSink(myOtherSink) // optional — adds one more sink at a time
FieldTypeDefaultNotes
EnabledboolfalseThe two endpoints below still work while this is off — they just return no events, since none were ever recorded.
FailureModeaudit.FailureModefail-openControls delivery fan-out across sinks within one dispatch batch: fail-open logs the error and continues to later sinks; fail-closed logs the error and stops processing that batch. It never changes whether the request that triggered the event succeeds or fails.
EnqueueFailureModefunc(audit.Event) audit.FailureModenil (fail-open)Decides, per event, whether a failed durable write blocks the triggering operation: fail-open (default) keeps authentication available and counts record_lost; fail-closed aborts the operation (the transaction rolls back). Fail-closed only fires where a transaction exists to roll back — at non-transactional sites (failed logins) it degrades to fail-open.
RetentionDaysint00 means keep forever. Must exceed the worst-case outbox row lifetime max(OutboxMaxAge, retryWindow + DeadLetterTTL) — startup validation rejects a configuration that would let retention delete a record whose delivery obligation still exists.
Workersint3Outbox dispatcher workers. Only started when at least one external sink is configured.
BatchSizeint50Outbox claim/delivery batch size.
FlushIntervaltime.Duration100msOutbox dispatch poll interval.
MaxAttemptsint10Delivery attempts before a row is marked dead-lettered (retained for DeadLetterTTL, then purged).
ClaimLeasetime.Duration10mHow long a dispatcher claim owns a row before a crashed or slow claimer loses it to another instance. Must exceed each sink's declared worst-case batch time; floor: 1m.
OutboxMaxAgetime.Duration7dAge bound on pending rows; older obligations are evicted (the record stays intact). Negative disables.
DeadLetterTTLtime.Duration7dHow long a dead-lettered row is kept as failure evidence. Negative keeps forever.
OutboxMaxRowsint100000Backlog emergency valve: dead-lettered rows are shed first, then oldest pending. Shredding never touches audit_log; shedding pending rows is logged at ERROR. Negative disables.
Sinks[]audit.EventSinknoneExtra destinations — Kafka, NATS, a webhook. Custom external sinks must also implement audit.BatchDeliveryTimeBounder. One outbox row tracks fan-out to the configured set.

WithAuditSink appends one sink at a time without needing to repeat the whole config; calling WithAudit more than once doesn't duplicate sinks already added.

Startup rejects an unsafe or unbounded sink/lease combination and refuses to enable external delivery when audit_outbox is missing. Apply the schema migration before deploying this write path; goauth.New returns an error instead of starting with a delivery configuration it cannot honor. Finite audit retention also rejects negative (unbounded) outbox clocks.

Built-in webhook sink

For real-time delivery to an external service (a SIEM, alerting, or your own automation endpoint), the library ships a first-party webhook sink implementing the same audit.EventSink interface:

sink, err := audit.NewWebhookSink(audit.WebhookConfig{
    Endpoint: "https://ops.example.com/hooks/auth",
    Secret:   "shared-hmac-secret", // optional, recommended
    Timeout:  10 * time.Second,     // optional, default 10s
})
if err != nil { /* empty endpoint */ }
goauth.WithAuditSink(sink)

Each audit event is delivered as one HTTP POST with the JSON-serialized audit.Event body. When Secret is set, every request carries an X-Signature: sha256=<hex> header — the HMAC-SHA256 of the raw body — which the receiver should recompute and verify (constant-time compare) before trusting the payload; a discovered URL alone must not allow forged events.

Delivery is durable and at-least-once: the delivery obligation is written in the same transaction as the record, so a process crash after commit cannot lose it. The dispatcher retries failed deliveries with exponential backoff (10s base, capped at 1h) up to MaxAttempts, then marks the row dead-lettered — the row is retained for DeadLetterTTL as failure evidence, never deleted outright. Because delivery is at-least-once, receivers must deduplicate on the event's id (not on the HMAC signature, which proves authenticity, not uniqueness). Delivery is ordered critical-first, oldest-first within a tier; login.failed-family noise ships best-effort behind it, so a login flood cannot starve the discrete compromise signals. Delivery never touches audit_log: retries, dead-lettering, and backlog eviction change only the outbox.

At the receiver boundary, make the event id a unique inbox key and record it in the same transaction as the side effect triggered by the event. If that insert conflicts because the ID was already processed, skip the side effect and still return a successful 2xx response. This covers the live-lease race where the original request is still being processed when another dispatcher steals its expired claim; both deliveries may arrive, but the receiver applies the event once. Returning an error for a recognized duplicate causes needless retries and can eventually dead-letter an event that was already handled.

Delivery health is visible

(*Auth).AuditDeliveryStats(ctx) (and the per-instance counters) reports pending, oldest_pending_age, dead_lettered, delivery_dropped, delivery_orphaned, delivery_duplicated, delivery_missed, record_lost, and record_blocked. oldest_pending_age is the primary "delivery is broken" alert; record_lost must be zero-or-alert; a non-zero delivery_orphaned rate means a broken invariant (bypassed retention validation or manual deletes), not routine pressure.

The database claim rechecks the retry deadline before incrementing the attempt count. A dispatcher that selected an event before another dispatcher scheduled backoff cannot claim it early or spend another attempt during that delay.

Consumer notes

Sinks are read once at startup: a sink added after records have already run (WithAuditSink on a live instance) gets no outbox rows for events recorded before it was registered — those events went to the sinks that existed then. Register every delivery sink before the first request.

The outbox tracks one obligation per event, not one row per sink. If an earlier sink succeeds and a later sink fails, retrying the obligation may redeliver the event to the earlier sink. This is another reason every receiver must make event-ID deduplication atomic with its side effect.

This is separate from application logging

config.Logger (the *slog.Logger you pass to WithLogger) is for operational logs — errors, warnings, debug output. Audit events are a distinct, structured stream meant to answer "who did what, when" for security review or compliance, and only exist at all if Enabled: true here.

Frontend client setup

The Client examples below call the same apiRequest(baseUrl, method, path, body) helper used throughout these guides — see Client → Setup. The named-method wrapper for both endpoints on this page lives on Client → Admin.

What gets recorded

Every event carries a type, a severity (info / warning / error / critical), whether it successed, and — depending on the event — an actor, a target user, a session, an org, an IP, and a user agent. The full type list, grouped by area:

AreaEvent types
Loginlogin.success, login.failed, logout, admin.login.success, admin.login.failed
Registrationuser.registered
Accountuser.name_changed, user.account_deleted
Emailemail.verification.sent, email.verified
Passwordpassword.changed, password.reset.requested, password.reset.completed
Sessionssession.created, session.refreshed, session.revoked, session.revoked_all, session.refresh_reuse_detected
OAuthoauth.login, oauth.linked, oauth.unlinked
Adminadmin.user.created, admin.user.updated, admin.user.deleted, admin.user.banned, admin.user.unbanned
App authorizationapp.authorization.initialized, app.library_permissions.changed, app.permission.created, app.permission.updated, app.permission.deleted, app.role.created, app.role.updated, app.role.deleted, app.role.grants_changed, app.user.role_changed
App-mode administrative session revocationadmin.session.revoked, admin.sessions.revoked
Rolesrole.changed
Organizationsorganization.created, organization.deleted, organization.member.invited, organization.member.removed, organization.member.role_changed
Admin — organizationsadmin.org.deleted, admin.org.member.added, admin.org.member.removed, admin.org.member.role_changed, admin.org.viewed

This list can grow across versions — treat event_type as an opaque string in your filters rather than an exhaustive enum you validate against.

With app permissions enabled, querying audit records requires goauth.app.audit.read or the protected administrator identity, with required MFA assurance. Supply ActorSessionID in direct administrative calls. Authorization changes record through the existing configured audit publisher inside their database transaction; the local permission CLI always uses database-only, fail-closed audit. Removing library definitions records their removed role links, and reinstalling never restores those links.

Events emitted before an account is resolved — failed/locked login, admin login failure, verification send, and password-reset request — leave actor_id null and put the submitted address in metadata.email. This keeps PostgreSQL's UUID-typed actor column valid while preserving the security signal.

Admin-organization events are always distinct from the self-service ones

A platform admin using auth.Services().Org.AdminDeleteOrg/AdminRemoveMember/AdminUpdateMemberRole publishes admin.org.*, never the plain organization.* type the org's own owner/admin would publish for the same action — so "the owner deleted their org" and "a platform admin force-deleted it" never look identical in the log. admin.org.deleted also carries the org's name/slug in its metadata, snapshotted at the moment of deletion — the organizations row is hard-deleted, so nothing else could resolve orgId back to a name afterward (the same limitation as the deleted-user email case below, applied to orgs).

Querying audit logs

List all audit logs — GET /admin/audit-logs

Query params (all optional)

ParamTypeNotes
offsetintDefault 0
limitintDefault 50, max 200
event_typecomma-separated stringMatches any of the listed types — event_type=login.success,logout. A single value still works exactly like before.
actor_idstringWho performed the action, by ID
actorEmailstringWho performed the action, by email — resolved to an ID server-side. 404 user_not_found if no user has it. If both actor_id and actorEmail are given, actorEmail wins.
target_user_idstringWho the action was performed on, by ID
targetEmailstringWho the action was performed on, by email — same resolve-and-win rule as actorEmail
session_idstring
org_idstring
deviceTypestringExact match against the parsed user agent's device type — mobile, desktop, tablet, or bot
ipstringExact match against the stored IP
searchstringSubstring match across metadata, user agent, IP, and event type — a general free-text box, composes with every field filter above via AND
from, toRFC3339 datetimeIndependently optional — either, both, or neither. Silently ignored if the value doesn't parse — no error, just no filter applied
successtrue | falseAnything else is ignored

actorEmail/targetEmail only find currently-existing users

Both resolve against the current users table by email. If the user was later deleted or changed their email, you can't look up their historical events this way — filter by actor_id/target_user_id instead if you already have it (e.g. from a linked event).

Response (200 OK)

{
  "events": [
    {
      "id": "...",
      "type": "login.failed",
      "severity": "warning",
      "success": false,
      "actorId": null,
      "actorEmail": null,
      "targetUserId": null,
      "targetEmail": null,
      "sessionId": null,
      "orgId": null,
      "ip": "192.168.1.1",
      "userAgent": "Mozilla/5.0 ...",
      "requestId": "...",
      "metadata": {},
      "createdAt": "2026-08-09T12:00:00Z"
    }
  ],
  "limit": 50,
  "offset": 0
}

The list response carries no total — fetch it from Count audit logs below.

actorEmail/targetEmail are resolved server-side from actorId/targetUserId for the returned page — not stored, and null if the corresponding *Id is null or that user no longer exists.

Errors

CodeStatusCause
user_not_found404actorEmail/targetEmail given but no user has that email
forbidden403Caller isn't an admin
internal_error500Query failure against the store

Empty, not an error, if audit logging was never enabled

WithAudit(AuditConfig{Enabled: true}) being unset just means no events were ever published — the query still runs, it just returns {"events": [], ...} (and /count returns {"count": 0}).

curl

curl "https://api.myapp.com/admin/audit-logs?event_type=login.failed&limit=50" \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt

Programmatic (Go)

Go examples on this page import github.com/nazimdjebloun/go-auth/api for operation inputs and results.

result, err := auth.Services().Admin.ListAuditLogs(ctx, api.AdminListAuditLogsInput{
    ActorID:    adminID, // the admin making this call
    EventTypes: []string{"login.failed"},
    Limit:      50,
})
// result.Events

auth.Services().Admin.ListAuditLogs — not auth.Services().AuditLog.List directly — is the supported programmatic path: it resolves EventActorEmail/TargetEmail to IDs and enforces that the calling actor is an admin, like the other auth.Services().Admin methods.


Client

const { events } = await apiRequest(
  API_BASE, "GET", "/admin/audit-logs?event_type=login.failed&limit=50"
);

See Client → Audit logs for the wrapper, data table, and filters.


Count audit logs — GET /admin/audit-logs/count

The matching total for the list, split into its own call so a paginated log view doesn't run a COUNT(*) on every page. Takes every filter param of the list except offset/limit.

Response (200 OK): { "count": 1000 }

n, err := auth.Services().Admin.CountAuditLogs(ctx, api.AdminListAuditLogsInput{
    ActorID:    adminID,
    EventTypes: []string{"login.failed"},
})
const { count } = await apiRequest(API_BASE, "GET", "/admin/audit-logs/count?event_type=login.failed");

See Client → Audit logs for the wrapper.

GET /admin/users/{id}/audit-logs/count is the same, scoped to one user (see below).


List one user's audit logs — GET /admin/users/{id}/audit-logs

Identical query params and response shape to the endpoint above — this one just forces target_user_id to the {id} in the path, overriding anything passed in the query string for target_user_id/targetEmail. Its total lives at GET /admin/users/{id}/audit-logs/count.

curl

curl "https://api.myapp.com/admin/users/b3f1.../audit-logs?limit=50" \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt

Programmatic (Go)

result, err := auth.Services().Admin.ListAuditLogs(ctx, api.AdminListAuditLogsInput{
    ActorID:      adminID,
    TargetUserID: &userID,
    Limit:        50,
})

Client

const { events } = await apiRequest(API_BASE, "GET", `/admin/users/${userId}/audit-logs`);
const { count } = await apiRequest(API_BASE, "GET", `/admin/users/${userId}/audit-logs/count`);

See Client → Audit logs for the wrapper and data table.


Next

  • Admin — most of the actions that show up as admin.* events here
  • Routes — full param reference

On this page