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 it's on, every significant thing that happens — logins, password changes, admin actions, org membership changes — gets published as a structured audit.Event to an internal queue, processed by background workers, and (if you configured a store) queryable through the two endpoints below.

Configuration

goauth.WithAudit(goauth.AuditConfig{
    Enabled:       true,                    // optional, default false
    FailureMode:   audit.AuditFailureOpen,   // optional, default fail-open — or audit.AuditFailureClosed
    RetentionDays: 0,                       // optional, default 0 — keep forever (0 = forever)
    QueueSize:     1000,                     // optional, default 1000
    Workers:       3,                        // optional, default 3
    BatchSize:     50,                       // optional, default 50
    FlushInterval: 100 * time.Millisecond,   // optional, default 100ms
    Sinks:         []audit.EventSink{myKafkaSink}, // optional, default none
})
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 published.
FailureModeaudit.AuditFailureModefail-openControls asynchronous sink fan-out after a sink fails: 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.
RetentionDaysint00 means keep forever.
QueueSizeint1000Events are published asynchronously onto this queue. If it is full, the event is dropped with a warning regardless of FailureMode; publishing never blocks or fails the triggering request.
Workersint3Background goroutines draining the queue.
BatchSizeint50Events per batch write to the store.
FlushIntervaltime.Duration100msMax time a partial batch waits before flushing anyway.
Sinks[]audit.EventSinknoneExtra destinations — Kafka, NATS, a webhook — fired in addition to the queryable store.

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.

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, login.locked, logout, admin.login.success, admin.login.failed
Registrationuser.registered
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
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.

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)

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

AdminService.ListAuditLogs — not auth.Services.AuditLog.List directly — is the supported programmatic path: it's what resolves EventActorEmail/TargetEmail to IDs and enforces the calling actor is an admin, same as every other auth.Services.Admin.* method.


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, goauth.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, goauth.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