Revaulter
GitHub

Audit events

Revaulter records security-relevant actions to a durable, append-only v2_audit_events table. This is separate from the application log (written to stdout): ordinary log messages still go to the log stream, while audit rows are the long-term history of who did what.

Each user can see their own events in the web UI, under Settings → Audit log. There’s no admin UI or REST API for reading the events of all users yet, so operators must query the table directly with SQL, or stream the events to a SIEM.

What gets recorded#

Each row records one action, such as a confirmed request, a regenerated request key, or a deleted passkey. Some failed and denied attempts are recorded too, such as failed logins.

Events are written through one of two paths:

  • In the same transaction: for security-critical changes, the audit row is written in the same transaction as the change. If writing the audit row fails, the change is rolled back too. This guarantees that a “request confirmed” row exists if and only if the request was actually confirmed.
  • Best-effort: for events that are only observational in nature (logouts, expired requests, login failures), the audit row is written separately. If writing it fails, Revaulter logs a warning, and the user’s action isn’t affected.

Schema#

columntypenullablenotes
idTEXT (SQLite) / UUID (Postgres)noTime-sortable primary key (UUIDv7 format)
created_atINTEGER (Unix seconds)noWhen the audit row was written
event_typeTEXTno<area>.<verb>: see the list below
outcomeTEXTnosuccess | failure | denied
auth_methodTEXTnosession | request_key | request_oidc (a JWT from a trusted OIDC issuer) | system | none (set on rows from handlers that run before any authentication is established, e.g. auth.login_finish failures)
actor_user_idTEXTyesThe user who performed the action, NULL for unauthenticated failures and pure system events
target_user_idTEXTyesThe user the action affects (often equal to actor_user_id)
signing_key_idTEXTyesSet for signing_key.* events
credential_idTEXTyesSet for auth.credential_* events and auth.login_finish
request_stateTEXTyesThe v2 protocol request state for request.* events
http_request_idTEXTyesCorrelates the audit row with the HTTP access log
client_ipTEXTyesNULL for system events
user_agentTEXTyesCapped at 512 chars
metadataTEXT (SQLite) / JSONB (Postgres)noFree-form JSON, capped at 4 KiB, default {}
seqINTEGER (SQLite only)noInternal: tracks progress when streaming to a SIEM. Assigned by the database and not included in backups
xact_idXID8 (Postgres only)noInternal: tracks progress when streaming to a SIEM. Assigned by the database and not included in backups

Event types#

Event types are named <area>.<verb>, with both parts in snake_case. Revaulter records only the event types listed here, and rejects any other value.

Auth#

event_typeWhen it fires
auth.register_finishAccount creation completes
auth.finalize_signupSetup of the first passkey completes, and the user’s anchor public keys, wrapped keys, and request encryption keys are stored
auth.login_finishA login attempt finishes: outcome=success for accepted credentials, outcome=failure for rejected ones
auth.logoutUser logs out. The server doesn’t revoke the session token, so this records that the session cookie was cleared
auth.request_key_regenerateUser regenerates the CLI request key
auth.request_auth_methods_changeUser enables or disables the static request key or OIDC tokens. Metadata: {requestKeyEnabled, requestOidcEnabled}
auth.request_oidc_issuer_addUser adds a trusted OIDC issuer. Metadata: {issuerId, issuer, audience, subject, discovery}, with long values truncated
auth.request_oidc_issuer_deleteUser removes a trusted OIDC issuer. Metadata: same as auth.request_oidc_issuer_add
auth.allowed_ips_changeUser updates the list of allowed IPs. Metadata: {old_count, new_count}
auth.display_name_changeUser updates their display name
auth.wrapped_key_updateThe wrapped primary key and anchor key change. Metadata: {advance_epoch} (true when the change is a password change)
auth.credential_add_finishNew passkey registered
auth.credential_renamePasskey renamed
auth.credential_deletePasskey deleted

Requests#

event_typeWhen it fires
request.createThe CLI or API submits a new encrypt, decrypt, or sign request. Metadata: {operation, algorithm, keyLabel, note?, jwtIssuer?, jwtSubject?, jwtId?}
request.confirmUser approves a pending request. Metadata: {operation, algorithm, keyLabel, note?}
request.cancelUser cancels a pending request. Metadata: {operation, algorithm, keyLabel, note?}
request.expireA pending request expires before it’s approved or canceled. auth_method=system. Metadata: {operation, algorithm, keyLabel, note?}

The note field is included only when the request had a note. The jwt* fields are included only on request.create, when the request was authenticated with an OIDC token: they hold the token’s verified iss, sub, and jti claims, so the audit log shows which workload sent the request.

Signing keys#

event_typeWhen it fires
signing_key.createUser creates a signing key. Metadata: {algorithm, keyLabel, published, hasProof}
signing_key.publishUser publishes an existing signing key
signing_key.unpublishUser unpublishes a signing key
signing_key.deleteUser deletes a signing key
signing_key.auto_storeServer stored the public key of a signing key after a successful sign request, with no explicit user action

What is never recorded#

Audit events never contain sensitive material. Specifically, audit rows don’t store:

  • Request keys (rvk_…) or session tokens
  • Wrapped primary keys, wrapped anchor keys, or any encrypted blob
  • Raw WebAuthn credential JSON, attestation payloads, or signature bytes
  • Encrypted request/response envelopes
  • Webhook URLs or shared secrets
  • Allowed-IP lists in full (only their count)
  • ML-DSA / ES384 signatures of any kind

Retention#

Rows older than 30 days are deleted automatically by a background task that runs when Revaulter starts, and then every 24 hours.

When streaming to a SIEM is enabled, rows that haven’t been sent yet aren’t deleted, so a collector outage doesn’t lose events. Rows are kept for at most 90 days, after which they’re deleted even if they weren’t delivered. The revaulter_audit_stream_lag_seconds metric shows a backlog long before that.

Sample queries#

These queries use SQLite syntax. For Postgres, only the date calculations differ.

All events for a user in the last 24 hours

SELECT created_at, event_type, outcome, client_ip, metadata
FROM v2_audit_events
WHERE actor_user_id = 'user-123'
  AND created_at >= strftime('%s', 'now', '-1 day')
ORDER BY created_at DESC;

All denied or failed actions in the last week

SELECT created_at, event_type, actor_user_id, client_ip
FROM v2_audit_events
WHERE outcome IN ('failure', 'denied')
  AND created_at >= strftime('%s', 'now', '-7 days')
ORDER BY created_at DESC;

All signing key changes

SELECT created_at, event_type, actor_user_id, signing_key_id, metadata
FROM v2_audit_events
WHERE event_type LIKE 'signing_key.%'
ORDER BY created_at DESC;

Background expiries during a window

SELECT created_at, request_state, metadata
FROM v2_audit_events
WHERE event_type = 'request.expire'
  AND created_at BETWEEN ?1 AND ?2
ORDER BY created_at DESC;

Streaming to a SIEM#

Revaulter can send every audit event to an external HTTP collector as it’s written, such as Splunk HEC, Elastic, Vector, Fluent Bit, Cribl, Panther, or a plain webhook receiver. To enable this, set auditStreamUrl.

auditStreamUrl: "https://splunk.internal:8088/services/collector/raw"
auditStreamFormat: "ndjson"          # ndjson (default) | json
auditStreamKey: "Splunk 00000000-0000-0000-0000-000000000000"
auditStreamAuthHeader: "Authorization"
auditStreamBatchSize: 100            # 1–1000
auditStreamFlushInterval: "10s"      # 1s–5m
auditStreamEventTypes: []            # empty = all, "request.*" wildcards allowed

Events can be delivered more than once, so your collector must deduplicate on the event id. The id is a UUIDv7 that stays the same when an event is sent again. A collector that doesn’t deduplicate will count some events twice.

Only events created after you enable streaming are sent. Earlier rows are never sent.

Wire formats#

Both ndjson and json formats carry the same event object:

{
  "schemaVersion": 1,
  "source": "revaulter",
  "instanceId": "7Yk2…",
  "id": "019974c1-1f3a-7c4e-9b2d-6f1e8a4c0d55",
  "time": "2026-09-15T20:11:03Z",
  "eventType": "request.confirm",
  "outcome": "success",
  "authMethod": "session",
  "actorUserId": "u_9f2c…",
  "targetUserId": "u_9f2c…",
  "httpRequestId": "01JC…",
  "clientIp": "203.0.113.41",
  "userAgent": "Mozilla/5.0 …",
  "attributes": { "requestState": "3f7a…" },
  "metadata": { "operation": "decrypt", "algorithm": "A256GCM", "keyLabel": "prod-backup" }
}
  • Nullable columns are omitted, never emitted as null
  • attributes holds the correlation columns (requestState, signingKeyId, credentialId), which are set only on the event types they apply to. Entries with no value are dropped, and the object is omitted when none apply
  • metadata is the event’s own data. It’s always present (as an empty object {} when there’s nothing to include) and it’s never merged with attributes
  • time is RFC 3339 in UTC, with second precision. Sort and deduplicate on id, not on time
  • schemaVersion, source, and instanceId are repeated on every event, so each NDJSON line still identifies its source after a collector splits the batch
  • The internal shipping key (seq / xact_id) is never emitted

ndjson (the default) sends Content-Type: application/x-ndjson, with one compact object per line, and a newline after each line, including the last one. Splunk HEC /raw, Elastic, Vector, Loki, and Fluent Bit all accept it natively.

json sends Content-Type: application/json with a single envelope, for endpoints that want one well-formed JSON document per request:

{
  "schemaVersion": 1,
  "source": "revaulter",
  "instanceId": "7Yk2…",
  "sentAt": "2026-09-15T20:11:08Z",
  "count": 2,
  "events": []
}

events holds one event object per entry, in the same shape as an NDJSON line.

Request headers#

headervalue
Content-Typeapplication/x-ndjson or application/json, per format
User-Agentrevaulter/<version>
X-Revaulter-InstanceThe instance ID
X-Revaulter-Schema-Version1
X-Revaulter-Batch-CountNumber of events in the body
AuthorizationauditStreamKey, verbatim

auditStreamKey is sent exactly as written, like webhookKey, so include the scheme yourself: Splunk abc123, Bearer …, and ApiKey … all work. Set auditStreamAuthHeader when your collector expects the credential in a different header.

Private addresses#

auditStreamUrl can point at a private or internal address, as most collectors are. Redirects are never followed, so a redirect can’t be used to reach other internal addresses, such as a cloud metadata endpoint.

Filtering#

auditStreamEventTypes limits which events are sent. Each entry is either an exact event type (request.confirm) or an area wildcard (request.*). An empty list sends everything. If an entry doesn’t match any event type Revaulter records, Revaulter fails to start, instead of silently sending nothing.

Events that the filter excludes count as processed, so a narrow filter doesn’t stop old rows from being deleted.

Metrics#

metrictypelabels
revaulter_audit_stream_events_totalcounteroutcome=sent|filtered
revaulter_audit_stream_batches_totalcounteroutcome=sent|retried|failed
revaulter_audit_stream_lag_secondsgauge—
revaulter_audit_stream_backloggauge—

Alert on revaulter_audit_stream_lag_seconds: it catches collector outages, misconfiguration, and a stalled stream in a single metric.

Edit this page on GitHub