Audit Log: Hash Chain, Retention and Forwarding
Docs version v1 (main)Nauthera is pre-1.0, in a design-partner programUpdated
nauthera-server records security-relevant activity to an audit_events table in its own
database. Each record names its actor and outcome and, where they apply, its organization,
target, client, session, and request and trace IDs. The set of actions is closed: the
catalog is part of the API contract and can be read at GET /,
with the same scope and permission as the records (see Querying).
Every action, with its level, severity and the metadata it may carry, is listed in Audit Events. The main areas:
| Area | For example |
|---|---|
| Authentication | auth.sign_in.completed, auth., auth.account.locked, auth. |
| Sessions | session.created, session.revoked |
| MFA | mfa.totp.activated, mfa.passkey.registered, mfa.email.verified, mfa. |
| OAuth and OpenID Connect | oauth.token.issued, oauth.consent.decided, oauth.device.decided, oidc. |
| Users | user.created, user.password.changed, user.suspended, user.deleted |
| Organizations and clients | organization.updated, client.registered, client.applied, identity_ |
| Admin, edge and audit | admin.access.denied, role.granted, http.request.screened, audit.log.read |
| System | system.started, system.keys.loaded, system.audit.checkpoint, system. |
auth.sign_in.alerted records the email sent to a user after a sign-in from a browser or
network the account has not used before.
What a record says
Beyond its action, actor, target and outcome, a record carries what the server could tell about the request:
- Who sent it. The read API labels each record with
client_kind, what kind of software the request's User-Agent claims to be (browser,ai_agent,crawler,automation,libraryorunknown), andclient_agent, the product it names for anything but a browser, such asClaudeBot,Claude Codeorcurl. They are read from the storeduser_agenteach time a record is served, so older records are labelled too and the hash chain covers nothing new. A sender writes its own User-Agent, so both are a claim: nothing is refused or allowed on them. A record no request produced, or a coalesced summary, carries neither. They are not a filter and are not exported; an export carries theuser_agentthey are read from. The request log carries the same two fields. - The sign-in's network and second factor. Every sign-in record, completed or
refused, carries
network_zones: the names of the network zones its address fell in. One by a user who holds a second factor and was not asked for it carriesmfa_spared:remembered_browserornetwork_zone. One through an identity provider set tospareSecondFactorcarriesmfa_, whether or not the user holds a factor. One that some level's second-factor rules applied to carriesspared: identity_ provider mfa_rules, each rule's name after its level (deployment:,organization:,client:). - The client and session. Every record a sign-in makes, the session it creates and
the new-sign-in alert included, carries the
client_idthe sign-in was started for, as a claim (claimed_client_id), never as the verified client. An administrator's event names, in the client column, the admin client their token was minted for, the organization's console or machine client, and the session the token was issued in; a client being managed stays the target.
Levels
audit.level sets how much is recorded. Each level includes everything in the levels
before it:
| Level | Records |
|---|---|
off | Only the events that cannot be suppressed |
security | Authentication outcomes, credential and MFA changes, privilege grants, and every administrative change |
write (default) | Every other state change, such as updating a user or renaming an organization |
read | Reads too: list and get endpoints, UserInfo, introspection |
trace | High-volume protocol traffic, mainly token issuance and refresh |
The levels describe successes. A refusal or a server fault is recorded at security
whatever its event's level, so at the default every refused token request is kept and no
issued one is.
Fine-tune with globs over action names (* also matches across dots):
audit:
level: write
include: ["oauth.token.issued"] # record these regardless of level
exclude: ["mfa.status.read"] # never record these; beats level and includeA pattern in include or exclude that matches no action in the catalog is a startup
error, so a typo cannot silently do nothing.
Refusals that anyone can send at line rate, such as requests over the rate limit, bodies
that are too large or repeated headers, are coalesced per caller, endpoint and rule: the
first is recorded in full, and the rest of audit.coalesce_window (1m) becomes one
summary record carrying repeat_count, first_seen_at and last_seen_at.
Events that cannot be suppressed
Some events are recorded whatever level and exclude say, among them sign-ins,
deleting a user, changing a password, disabling MFA, granting and revoking a role,
forgetting a user's remembered browsers, revealing a client secret, reading the audit
log, and dropping an expired audit partition. The catalog marks each one as mandatory. An operator who could switch off the record of their own
destructive actions would have a privilege-escalation path. Only audit.enabled: false
stops these events, because it stops all recording, and even then the server records
that it was turned off. At startup the server logs exactly which events the resolved
policy suppresses.
Delivery and durability
- No request waits on the database. Every event is queued: mandatory events have a queue
of their own (
priority_buffer_size) that is written as soon as events arrive, and everything else is written in batches (buffer_size,batch_size,flush_interval). Anonymous callers may fill at most half of the mandatory queue, and its last eighth is kept for administrators and the server itself, so a flood of failed sign-ins cannot crowd out the record of an administrator's change. - A full queue drops rather than blocks. Every drop is counted in
nauthera_and reported as aaudit_ events_ dropped_ total system.audit.overflowrecord that carries the count. That record is a platform record, so through the API onlyplatform.auditholders see it. Alert onnauthera_before drops start.audit_ buffer_ depth - Mandatory events the database refuses are held and retried until it takes them, and
reported as
system.. When the database keeps refusing them, the server recordsaudit. write_ delayed system.audit.degraded, marks itsauditreadiness check degraded (the replica stays in service), and recordssystem.audit.recoveredonce writes succeed again. audit.fail_closed: truerefuses privileged changes, the admin API's user and organization changes, organization changes over the gRPC control plane and second-factor changes, with a503while the pipeline is degraded. The refusal comes before the change, so nothing is left half-done. Sign-in, registration, token requests and other anonymous endpoints are never refused for the audit log's health. With it on, startup also waits until its bootstrap records are written. It is off by default.- On shutdown, the queues are drained after the listeners stop and before the database closes.
Retention
audit.retention defaults to 9600h (400 days). That covers PCI DSS 10.5.1's twelve
months with a month of slack. The table is partitioned by month,
partition_ahead_months (6) partitions are created ahead of time, and whole partitions
are dropped once they expire. Each one is recorded as system.
or system..
The append-only role
By default the server maintains its own schema, so the role it connects as owns
audit_events and could change or delete history.
audit. takes migrations and partition maintenance out of
the server, and nauthera-server maintain, run as a separate job under the owning
credential, does them and exits. Name the server's own role in database.runtime_role,
and each maintain run grants it SELECT and INSERT on the audit log and nothing on
any partition. A server running as that role cannot change or remove a stored record.
With maintenance external, the server refuses to start while a migration it ships is
unapplied or no partition covers the current month, and warns while a partition for a
coming month is missing. Every boot logs one warning for each way its own
role could still rewrite the log, and records the list as audit_rewrite_routes on
system.audit.configured.
The Helm chart sets all of this up with appendOnlyAudit. See
Installation.
Tamper evidence
Every row is linked in a SHA-256 hash chain, one segment per writer and month. Every
audit. (1m), each writer signs a checkpoint of how far its
segments reach, with the Ed25519 key at audit.. A checkpoint is
recorded in the log as system.audit.checkpoint and written whole to the application log
at info as audit chain checkpoint, so your log shipping carries a copy off the host.
openssl genpkey -algorithm ed25519 -out checkpoint.pemUse a key of its own, never a token-signing key, and keep its public half where the
database's administrators cannot write. Without a key the checkpoints are unsigned and
the server warns at startup; audit. refuses to start
instead. A configured key that cannot be read fails startup.
The Helm chart provides the key. It sets audit. to the first of: a
path you set yourself, a key you supply in secrets., or, with
certManager.enabled, an Ed25519 key cert-manager issues
(certManager., on by default) and every replica shares. The issued
key is never rotated, and its public half is in the Secret's tls.crt, for the verifier
to trust. The key is mounted into the server's pods only. The chart's production profile
sets audit., which the chart also checks when it renders. See
Installation.
Checkpoint rows are written about once a minute whenever anything was written, an
administrator's own reads included, so the list, tail and counts leave
system.audit.checkpoint out unless the action filter matches it. A record is still
read by its id.
nauthera-server audit verify checks one month against the checkpoints, from both
copies, reading the database read-only:
nauthera-server audit verify --config /etc/nauthera/config.yaml \
--month 2026-10 \
--trusted-keys trusted-keys.json \
--app-log-checkpoints checkpoints-2026-10.jsonl \
--retention 400dIt trusts only the public keys in the trusted-keys file you give it, current and retired,
never a key the log names. It reports rows that were changed, deleted, forged or
reordered, a row filed under a month its segment does not belong to (misplaced), a
truncated tail, a deleted segment and missing or conflicting checkpoints.
It exits 0 with no finding, 1 with any, and 2 when it could not verify. Run it
monthly and after anything that touched the database outside the server. The server's
docs/CONFIGURATION.md has every flag, the trusted-keys format, key rotation and what
each finding means.
Forwarding
audit.export forwards the stored log, every organization's records and the platform's,
in the order they reached the database. Forwarding follows the database, so an exporter
that is down only falls behind: it never delays a request or drops an event. Each
exporter keeps its position in the database and resumes from it, every replica runs the
forwarder with a lease so one moves each exporter, and delivery is at least once:
de-duplicate on id.
The exporter built in appends each record as one line of JSON to a file, for a log shipper to read:
audit:
export:
file:
enabled: true
path: /var/log/nauthera/audit.jsonl # rotate with copytruncateEach record is a JSON object with schema_version: 1 and every column of the stored
record. Watch nauthera_ and
nauthera_.
Querying
curl -s "https://auth.example.com/v1/admin/orgs/default/audit-events?action=user.*,mfa.*&outcome=failure&limit=100" \
-H "Authorization: Bearer $TOKEN"This needs the nauthera.audit:read scope and the audit.read permission, on a token
issued to one of the organization's reserved admin clients, {slug}-admin-console or
{slug}-admin-m2m; a token from any other client is refused. In the organization where
they are held, org-auditor and org-owner grant the permission. platform-auditor and
platform-owner, held in the main organization, grant it in every organization. A
{slug}-admin-m2m token can read its organization's log, because that client holds
org-auditor. See Getting a token.
Dynamic registration refuses nauthera.* scopes.
Platform records name no organization: the system.* events, and refusals recorded before
a request's organization was resolved. Only a caller holding platform.audit, which
platform-auditor and platform-owner grant in the main organization, sees them,
beside the records of whichever organization it queries.
Reading the log is itself recorded, as audit.log.read, naming whose trail was read.
Filters:
| Parameter | Meaning |
|---|---|
from and to | RFC3339 window, at most 90 days wide; a wider one answers 400. Defaults to the last 30 days. |
action | Comma-separated action patterns (* and ? wildcards). |
category, outcome, severity, reason, actor_type, operation | Comma-separated values from the catalog's vocabularies. |
actor_id, target_id, target_type | Who did it, and to what. |
client_ip, client_net | An address, or the IPv4 /24 or IPv6 /64 it lies in. |
network_zone | Comma-separated network zones, at most 16: records whose address fell in one of them. Sign-ins record their zones. |
client_id | The client the server resolved, or the one the caller only named, which the record carries as claimed_client_id. |
session_id, request_id, trace_id | Correlation. session_id is the session's public sid. |
cross_tenant_only | Only actions taken on this organization by an actor from another one. |
Results use the same cursor pagination as the rest of the
admin API and can be sorted by id
or occurred_at. GET / returns a single record.
A page through a time window cannot follow the log as it grows, because a record can
become visible after records that occurred later. To follow it, poll
GET /, which reads in the order records reached
the database: keep the next_cursor every response carries, read again at once while
has_more is true, and pause when it is false.
GET / counts records per time bucket (one
minute, ten minutes, an hour or a day), optionally grouped by one column, under the same
filters. It needs the nauthera.insights:read scope and the insights.read permission.
Ranking
GET / returns the subjects behind the records the
filters match, by how many records each caused, most first: by=actor, target,
client (resolved or only named, as the client_id filter matches), client_ip or
network_zone. A record counts once under each zone it names, and once under the empty
key, outside every zone, when it names none. It takes the list's filters, at most 25
subjects come back, and the window spans at most 31 days. Reads of the audit log itself
are left out unless include_audit_reads is set.
curl -s "https://auth.example.com/v1/admin/orgs/default/audit-events/top?by=client_ip&action=auth.sign_in.completed&outcome=failure" \
-H "Authorization: Bearer $TOKEN"It needs nauthera.audit:read and audit.read. Ranking or filtering by address also
needs users.pii.read; without it an actor's or target's display name is withheld and
the id alone is returned. A zone name is not personal data, so ranking by zone needs
audit.read alone. Answers are cached for at least the dashboard's shortest refresh
interval, and a ranking past the aggregate deadline is answered 503 busy with
Retry-After. Reading one is recorded as audit.ranking.read at the trace level.
The admin dashboard shows the log with its filters, a
volume chart and who sent each request, and its
threat page ranks who is behind the
refused sign-ins. The Docker Compose stack also provisions a Grafana audit-log explorer
dashboard that reads audit_events directly from PostgreSQL (/d/nauthera-audit).
Known gaps
- Client and branding changes made over the gRPC control plane are recorded only when they are refused.
- Without
database.runtime_role, the server's role can still rewrite the log. The hash chain and checkpoints make such a change evident toaudit verify; they do not prevent it. - No endpoint exports a long range in one request. The tail walks the whole retained log
one organization at a time, and
audit.exportforwards all of it. - Checkpoints are signed with a key file, not a key held in a KMS; exported records do
not carry their hash-chain link; and
audit verifyruns only when you run it, with no scheduled check or alert built in.