Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Audit Events

Every authentication, credential issuance, and administrative action is recorded as an audit event. Events are stored in the audit_events table and browsable at /admin/audit.

Email addresses are masked to domain only, with an HMAC column alongside so you can correlate a user’s activity without the log itself holding their address. Events are enriched with the country code, ASN, and network organization resolved from the client IP.

The GeoIP databases are compiled into the server binary. They cannot be refreshed independently — new geolocation data arrives with a new Vouch release.

Audit Events

Authentication and key lifecycle

Event TypeDescription
login_successUser authenticated — FIDO2 passkey login, or a returning user signing in on the website via the upstream IdP (the latter has no authenticator_id)
login_failedFailed authentication attempt
enrollmentUser enrolled their first hardware key
logoutUser logged out (including RFC 7009 token revocation)
key_registeredAdditional hardware key registered (vouch register)
key_removedHardware key removed
key_renamedHardware key renamed
device_auth_approvedBrowser approved a CLI device-authorization request
key_registration_replayReplayed key-registration link rejected (possible attack)
identity_boundUpstream IdP identity (issuer + subject) bound to an account on its first IdP login; data.idp_issuer names the issuer
identity_bind_refusedIdP sign-in refused: the asserted email matched an account already bound to a different subject at the same issuer (possible upstream email reassignment); data.idp_issuer names the issuer

Credential issuance

Event TypeDescription
ssh_credentialSSH certificate issued; data includes the serial, principals, requesting agent, and expiry
aws_credentialAWS OIDC token issued; data includes the pinned IAM role_arn (the https://aws.amazon.com/roles claim), the requesting agent, and token expiry
github_credentialGitHub installation token issued or installation connected; data includes repositories and permissions
token_exchangeRFC 8693 token exchange (workload identity federation); data includes the client, audience, scope, and issued token type

OAuth clients

Event TypeDescription
oauth_token_issuedToken issued at /oauth/token (data.details carries the grant type)
oauth_token_revokedAll tokens for an application revoked
oauth_client_registeredOAuth client registered (RFC 7591 or applications UI)
oauth_client_updatedOAuth client configuration updated
oauth_client_deletedOAuth client deleted
oauth_secret_addedClient secret added
oauth_secret_revokedClient secret revoked

Administration and organization

Event TypeDescription
scim_operationSCIM provisioning operation (data carries operation and resource type)
admin_promoteOrg-admin role granted
admin_demoteOrg-admin role removed
admin_deactivateUser account deactivated
admin_activateUser account reactivated
admin_revoke_credentialsAdmin revoked a member’s keys, sessions, and certificates
admin_remove_userAdmin removed a member from the organization
policy_deniedA posture or temporal policy denied credential issuance
admin_policy_togglePosture policy enabled or disabled
admin_policy_createCustom posture policy created
admin_policy_updateCustom posture policy updated
admin_policy_deleteCustom posture policy deleted
admin_create_scim_tokenSCIM API token created
admin_delete_scim_tokenSCIM API token deleted
admin_revoke_scim_tokenSCIM API token revoked
org_domain_addedAdditional email domain added to the organization
org_domain_verifiedAdditional email domain ownership verified
org_domain_removedAdditional email domain removed by an admin
org_domain_expiredStale additional domain removed by the cleanup task (never verified, or unverified past its TTL)
org_domain_unverifiedVerified additional domain flipped to unverified after repeated DNS re-check failures
org_subdomain_claimedIssuer subdomain claimed for the organization
org_subdomain_releasedIssuer subdomain released (by an admin, or automatically when its backing domain became unverified)
org_issuer_key_rotatedPer-org issuer signing keys rotated (one event per algorithm)
org_issuer_key_revokedPer-org previous signing keys revoked (one event per algorithm)
org_issuer_key_emergency_rotationEmergency rotation of per-org issuer keys (one event per algorithm)

Retention

Events fall into three retention classes. The class is a property of the event type; it is not configurable.

ClassGoverned byContains
AuthenticationVOUCH_AUTH_EVENTS_RETENTION_DAYS (default 90)Logins, enrollment, logout, key and device-auth lifecycle, SCIM operations
OAuth and credentialsVOUCH_OAUTH_EVENTS_RETENTION_DAYS (default 90)Credential issuance, token issue/revoke, client registration — the high-volume events
Kept forevernothing — never deletedEvery administrative action, OAuth client and secret lifecycle, and all organization domain, subdomain, and issuer-key events

The third class is the one to know about. Administrative and organization-lifecycle records are never purged by the cleanup task, regardless of how you set the two retention variables. That is deliberate: these are the records that answer “who granted this person admin, and when”, and they are low-volume enough to keep indefinitely. Plan database growth accordingly, and if a regulation requires you to delete them, that is a manual database operation.

# Keep authentication events for two years, credential events for 90 days.
VOUCH_AUTH_EVENTS_RETENTION_DAYS=730
VOUCH_OAUTH_EVENTS_RETENTION_DAYS=90

Expired events are removed by the background cleanup task, which runs every VOUCH_CLEANUP_INTERVAL minutes (default 15). Setting the interval to 0 disables cleanup entirely, and events then accumulate without bound.

Retention values must not be negative. The server rejects a negative value at startup, because a negative window produces a cutoff in the future — which would delete the entire audit log on the first cleanup pass.

Browsing and exporting

/admin/audit provides a paginated view scoped to your organization, with filters for event type, user ID, email, and a date range.

For programmatic access — SIEM ingestion, backfills, ad hoc scripting — use the audit events API described below. The raw audit_events table is still available as an operator escape hatch:

# SQLite
sqlite3 /data/vouch.db \
  "SELECT * FROM audit_events WHERE created_at > datetime('now', '-1 day');"

# PostgreSQL
psql "$VOUCH_DATABASE_URL" -c \
  "SELECT * FROM audit_events WHERE created_at > now() - interval '1 day';"

Application logs are separate from audit events and go to stdout — see Monitoring and Metrics for structured logging and the x-fapi-interaction-id correlation header.

Audit Events API

GET /api/v1/org/audit-events returns audit events scoped to your organization (the primary domain plus any verified additional domain) in ID order.

Authentication

Two auth methods are accepted; cookie (browser session) auth is rejected outright, since this endpoint is meant for unattended pollers as much as interactive use:

  • Org API token with the audit:read scope — the token type used for SCIM provisioning, generalized to carry additional scopes. Mint one on /admin/scim-tokens (check “Also grant read-only audit log access”) or via POST /api/v1/org/scim-tokens with "audit_read": true. A token minted without that option (or before this feature existed) is rejected with 403.
  • Org-admin user session — a FIDO2-authenticated org admin’s access token (Authorization: Bearer or DPoP), the same credential used for the other /api/v1/org/* endpoints.
curl -H "Authorization: Bearer $VOUCH_AUDIT_TOKEN" \
  "https://vouch.example.com/api/v1/org/audit-events"

Filters

ParameterDescription
event_typeComma-separated list of event types (e.g. login_success,login_failed). Unknown or empty values return 400 rather than silently matching nothing.
user_idExact match.
emailExact match (HMAC lookup, case-insensitive).
since / untilRFC 3339 timestamps; only events strictly after since and strictly before until.
afterForward cursor: the id of the last event from a previous page. Returns events in ascending (oldest-first) order — the shape a poller wants. Takes precedence over before.
beforeBackward cursor: the id of the last event from a previous page. Returns events in descending (newest-first) order, matching /admin/audit.
limitPage size, default 500, maximum 1000.
formatocsf to project events into OCSF (see below); omitted for native JSON.

With neither after nor before set, the first call defaults to an ascending walk from the start of retained history — a poller with no saved cursor yet can call the endpoint with no parameters and just start following next_cursor forward. Pass before explicitly to browse backward from the newest event instead.

Response

Default response is a JSON envelope:

{
  "events": [
    {
      "id": "01920000-...",
      "event_type": "login_success",
      "user_id": "01910000-...",
      "email_domain": "example.com",
      "email_hmac": "9f86d0...",
      "created_at": "2026-01-01T00:00:03.512Z",
      "data": { "authenticator_id": "..." }
    }
  ],
  "next_cursor": "01920000-..."
}

email_hmac is included — it is the documented correlation key for tying events to a specific user without storing their address in the log (see “Email masking” above), and is already org-scoped.

Cursor semantics and delivery guarantee

next_cursor is present whenever there may be more matching events; pass it back as after (or before, if you’re walking backward) to continue. IDs are UUID v7 (time-ordered) and every event is written before the request that caused it receives its response, but concurrent requests can still commit in a different order than they minted IDs — a naive high-water-mark poller that just tracks “the highest ID seen” can miss an event that commits a moment after a higher ID from an overlapping request.

The API’s delivery guarantee instead of ID ordering: an event is never returned with created_at newer than now - 30s, regardless of the until you pass. A poller that requests after=<last cursor> no more often than every 30 seconds, and persists the returned next_cursor after each successful page, will not miss events that commit within that 30-second window. Because pages can be byte-capped (see NDJSON below), always follow next_cursor until a page comes back without one rather than assuming one poll drains everything new.

An event’s ID and created_at are stamped together immediately before its insert, and the insert completes before the response is sent, so a committed event’s timestamp trails its commit only by the write itself. Audit writes are best-effort, however: a write that fails outright is logged server-side and not retried, so the event is absent rather than late — treat the guarantee as best-effort under write-path failure rather than a hard real-time bound.

NDJSON

Send Accept: application/x-ndjson for one JSON object per line instead of the envelope. Useful for streaming into a poller that appends to a file or pipes into jq. Responses are buffered server-side and capped at 5 MiB; if a page would exceed that, the response stops at the last complete line and a Link: <...>; rel="next" header carries the cursor for the rest — always follow it the same way you’d follow next_cursor in the JSON envelope.

curl -H "Authorization: Bearer $VOUCH_AUDIT_TOKEN" \
     -H "Accept: application/x-ndjson" \
     "https://vouch.example.com/api/v1/org/audit-events" | jq -c .

SIEM poller examples

Microsoft Sentinel (Codeless Connector Framework RestApiPoller): poll on an interval, carry next_cursor forward as the after query parameter between polls, and treat the 30s lag window as the platform’s ingestion delay tolerance.

Splunk / Elastic (generic HTTP poll): configure a REST/HTTP input against GET /api/v1/org/audit-events?format=ocsf with the bearer token, checkpoint on the response’s next_cursor, and poll no more frequently than every 30 seconds.

Reads are not audited

Polling this endpoint does not itself write an audit event — that would create a feedback loop of one event per poll. Reads are logged to the application’s structured log (tracing::info!, token or user ID, event count) instead.

OCSF Mapping

?format=ocsf projects each event into OCSF 1.9.0, mapping Vouch’s ~40 event types onto four Identity & Access Management classes. Native JSON stays the canonical, lossless representation — this is a projection for SIEM ingestion, and every field Vouch records is still present in data.

status_id is Success unless the event type is itself a failure (login_failed, for example) or data carries a top-level refusal member. admin_remove_user, admin_deactivate, and a scim_operation delete or deactivating update record "refusal": "last_admin" when removing the organization’s last active admin was refused after the member’s sessions and certificates had already been revoked; those rows export with status_id Failure. SCIM rows written by v2026.9.4 carry the refusal inside details instead and export as Success.

Seven event types map to OCSF activity_id: 99 (“Other”) because the OCSF IAM classes have no predefined activity for them. Per the OCSF 1.9.0 spec, when activity_id is 99 the activity_name attribute must carry a source-specific label (not the literal “Other”), so each of these events emits a distinct activity_name and also preserves the original Vouch event_type in unmapped.event_type for cross-product correlation:

Event TypeOCSF Classactivity_idactivity_name
admin_promoteAccount Change (3001)99Admin Promote
admin_demoteAccount Change (3001)99Admin Demote
admin_revoke_credentialsAccount Change (3001)99Admin Revoke Credentials
identity_boundAccount Change (3001)99Identity Bound
key_renamedAccount Change (3001)99Key Renamed
oauth_token_revokedAuthorize Session (3003)99OAuth Token Revoked
scim_operationEntity Management (3004)99SCIM Operation
Event TypeOCSF Class UIDOCSF Class Name
login_success3002Authentication
login_failed3002Authentication
logout3002Authentication
device_auth_approved3002Authentication
identity_bind_refused3002Authentication
enrollment3001Account Change
identity_bound3001Account Change
key_registered3001Account Change
key_removed3001Account Change
key_renamed3001Account Change
key_registration_replay3001Account Change
admin_promote3001Account Change
admin_demote3001Account Change
admin_activate3001Account Change
admin_deactivate3001Account Change
admin_revoke_credentials3001Account Change
admin_remove_user3001Account Change
ssh_credential3003Authorize Session
aws_credential3003Authorize Session
github_credential3003Authorize Session
token_exchange3003Authorize Session
oauth_token_issued3003Authorize Session
oauth_token_revoked3003Authorize Session
scim_operation3004Entity Management
oauth_client_registered3004Entity Management
oauth_client_updated3004Entity Management
oauth_client_deleted3004Entity Management
oauth_secret_added3004Entity Management
oauth_secret_revoked3004Entity Management
policy_denied3002Authentication
admin_policy_toggle3004Entity Management
admin_policy_create3004Entity Management
admin_policy_update3004Entity Management
admin_policy_delete3004Entity Management
admin_create_scim_token3004Entity Management
admin_delete_scim_token3004Entity Management
admin_revoke_scim_token3004Entity Management
org_domain_added3004Entity Management
org_domain_verified3004Entity Management
org_domain_removed3004Entity Management
org_domain_expired3004Entity Management
org_domain_unverified3004Entity Management
org_subdomain_claimed3004Entity Management
org_subdomain_released3004Entity Management
org_issuer_key_rotated3004Entity Management
org_issuer_key_revoked3004Entity Management
org_issuer_key_emergency_rotation3004Entity Management

An event type this server doesn’t recognize (a newer kind an older binary doesn’t know about yet) is emitted as an OCSF Base Event (class_uid: 0) with the raw type preserved in unmapped.event_type, never a 500.

This table and the mapping code are kept in sync by an automated test (ocsf_class in handlers/api/org/ocsf.rs) that fails the build if they drift apart.

Known gap: events written before the NULL-domain fix

Org scoping (both /admin/audit and the API) filters by email_domain. Four write sites used to insert SCIM and org-lifecycle cleanup events with a NULL email_domain (they act on behalf of an organization rather than a specific user, so had no email to derive a domain from). Events written by those code paths before the fix landed remain invisible to org-scoped reads — there is no backfill migration, since the org that wrote them is only recoverable from application logs, not the row itself. Events written after the fix carry the org’s primary domain and are visible normally.