Skip to content

The v1 docs follow nauthera-server's main branch and its 0.x releases on the way to 1.0, so fields and APIs can still change.

v1

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 /v1/admin/audit-catalog, 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:

AreaFor example
Authenticationauth.sign_in.completed, auth.registration.completed, auth.account.locked, auth.password_reset.completed
Sessionssession.created, session.revoked
MFAmfa.totp.activated, mfa.passkey.registered, mfa.email.verified, mfa.recovery_code.consumed
OAuth and OpenID Connectoauth.token.issued, oauth.consent.decided, oauth.device.decided, oidc.logout.token.delivered
Usersuser.created, user.password.changed, user.suspended, user.deleted
Organizations and clientsorganization.updated, client.registered, client.applied, identity_provider.applied
Admin, edge and auditadmin.access.denied, role.granted, http.request.screened, audit.log.read
Systemsystem.started, system.keys.loaded, system.audit.checkpoint, system.dependency.degraded

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, library or unknown), and client_agent, the product it names for anything but a browser, such as ClaudeBot, Claude Code or curl. They are read from the stored user_agent each 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 the user_agent they 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 carries mfa_spared: remembered_browser or network_zone. One through an identity provider set to spareSecondFactor carries mfa_spared: identity_provider, whether or not the user holds a factor. One that some level's second-factor rules applied to carries 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_id the 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:

LevelRecords
offOnly the events that cannot be suppressed
securityAuthentication 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
readReads too: list and get endpoints, UserInfo, introspection
traceHigh-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 include

A 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_audit_events_dropped_total and reported as a system.audit.overflow record that carries the count. That record is a platform record, so through the API only platform.audit holders see it. Alert on nauthera_audit_buffer_depth before drops start.
  • Mandatory events the database refuses are held and retried until it takes them, and reported as system.audit.write_delayed. When the database keeps refusing them, the server records system.audit.degraded, marks its audit readiness check degraded (the replica stays in service), and records system.audit.recovered once writes succeed again.
  • audit.fail_closed: true refuses privileged changes, the admin API's user and organization changes, organization changes over the gRPC control plane and second-factor changes, with a 503 while 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.audit.partition_created or system.audit.partition_dropped.

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.partition_maintenance: external 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.checkpoint_interval (1m), each writer signs a checkpoint of how far its segments reach, with the Ed25519 key at audit.checkpoint_key_path. 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.pem

Use 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.checkpoint_key_required: true refuses to start instead. A configured key that cannot be read fails startup.

The Helm chart provides the key. It sets audit.checkpoint_key_path to the first of: a path you set yourself, a key you supply in secrets.auditCheckpointKey, or, with certManager.enabled, an Ed25519 key cert-manager issues (certManager.auditCheckpointKey, 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.checkpoint_key_required, 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 400d

It 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 copytruncate

Each record is a JSON object with schema_version: 1 and every column of the stored record. Watch nauthera_audit_export_lag_seconds and nauthera_audit_exported_records_total.

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:

ParameterMeaning
from and toRFC3339 window, at most 90 days wide; a wider one answers 400. Defaults to the last 30 days.
actionComma-separated action patterns (* and ? wildcards).
category, outcome, severity, reason, actor_type, operationComma-separated values from the catalog's vocabularies.
actor_id, target_id, target_typeWho did it, and to what.
client_ip, client_netAn address, or the IPv4 /24 or IPv6 /64 it lies in.
network_zoneComma-separated network zones, at most 16: records whose address fell in one of them. Sign-ins record their zones.
client_idThe client the server resolved, or the one the caller only named, which the record carries as claimed_client_id.
session_id, request_id, trace_idCorrelation. session_id is the session's public sid.
cross_tenant_onlyOnly 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 /v1/admin/orgs/{slug}/audit-events/{id} 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 /v1/admin/orgs/{slug}/audit-events/tail, 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 /v1/admin/orgs/{slug}/audit-events/counts 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 /v1/admin/orgs/{slug}/audit-events/top 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 to audit 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.export forwards 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 verify runs only when you run it, with no scheduled check or alert built in.