Audit trail
A permanent, append-only record of who changed users, roles, network restrictions, API keys and invitations, and when.
Each realm with an auth section keeps an audit trail: one record per security-sensitive action or event, with who did it, to what, when and from where. It answers questions such as “who gave Bob the admin role?” or “which key created this user?”. Records can be read, but not changed or deleted: they expire after the realm’s retention period.
What is recorded
| Action | When | Details |
|---|---|---|
user.create | An administrator (or backd-server bootstrap) creates a user | password (whether one was set), roles (seeded from realm.yaml) |
user.signup | A user signs up | invited, roles |
user.password | An administrator sets a user’s password | |
password.expire | An administrator expires a user’s password, or every password of the realm (target realm) | scope (user or realm), reason; for the realm also accounts, sessions, keep_sessions |
password.reset_link | An administrator made a reset link for a user | expires_at, redirect_to; never the token |
password.expire_notify | The job that tells the users to choose a new password ended | job_id, queued, skipped, deferred |
session.revoke_all | An administrator ended every session of the realm | sessions |
user.password_change | A user changes their own password | |
user.verify_email | An administrator marks an email as verified, or not; or a user verifies it with the link in their email | verified |
user.password_reset | A user sets a new password with a reset link | verified_address |
user.email_changed | A user’s address changes: confirmed by the new address, or set by an administrator | by (self or admin) |
user.email_change_reverted | The old address undid a change | |
user.purged_unverified | A worker deletes accounts that never verified their address | count, older_than |
push.test | An administrator sent a test push to a user | send, kind |
device.removed | An administrator removes a user’s push device | platform, device |
user.claimed | A link or code verified an address that was never verified, and took the account away from whoever made it: password, providers, second factor and sessions are removed | methods, mfa |
function.invoke_manual | An administrator runs a function by hand (admin API) | as (the user’s id, never their email, or null) |
files.versions.regenerate | An administrator runs backd files regenerate (admin API) | version, missing_only, job_id |
storage.reconcile | An administrator runs backd storage reconcile (admin API) | delete, orphans (how many were found), deleted |
export, export.done | An administrator starts and ends an export (admin API); never any content | database, collections, files, partial; at the end collections, documents, files, bytes, complete |
export.auth | An export includes authentication data; never the data or the key | collections, key_fingerprint |
import, import.done | An administrator starts and ends an import (admin API); never any content | mode, dry_run, skip_invalid, databases, source_realm; at the end the counts by outcome (created, replaced, skipped, unchanged, conflict, invalid), files, complete |
storage.check | An administrator runs a storage check (admin API); never the keys or anything stored | ok, provider, bucket |
data.check | An administrator starts a schema check (admin API); the target is data:<database>/<collection>, data:<database> or data:*; never the report | collections, limit, job_id |
schedule.pause, schedule.resume | An administrator pauses or resumes a function’s schedule (admin API); only a change is recorded; the target is schedule:<database>/<function> | none |
triggers.gap_resolve | An administrator closes a gap of the trigger dispatcher (admin API, backd triggers resolve); the target is trigger-gap:<id> | none |
triggers.rescan | An operator runs backd-server triggers rescan for a collection; the actor is cli; the target is <database>/<collection> | trigger, since, scanned, queued, run |
job.cancel, job.rerun | An administrator cancels a job, or queues a finished one again (admin API); the target is job:<id> | function; for a re-run also new_job |
user.disable, user.enable | An administrator disables or re-enables a user | |
user.delete | An administrator erases a user (the tombstone is made; the data work is in the job) | job_id |
user.erased | The erase job finished, or failed for good | job_id and counts per database/collection/operation; needs_attention and error when it failed. Never content |
user.data_export_requested, user.data_export_requested_silently | A user asked for a copy of their data, or an administrator made one for them and told them (requested) or did not (_silently, with the reason); the target is user:<id> | export, ticket (administrator), reason (silent) |
user.data_exported, user.data_export_failed | The export is ready, or failed for good | export, size, by; error |
user.data_export_downloaded, user.data_export_expired | A link to the package was given or its bytes sent, with who took it; the package was deleted at the end of its retention | export, by |
user.delete_account | A user deletes their own account (it is deactivated, not erased) | |
user.networks | A user’s network restrictions change, through the API or from realm.yaml | admin_networks, login_networks |
identity.apple_revoked | Apple revoked a user’s tokens after their account was erased or deleted, or Apple was unlinked | |
identity.linked | A provider account is linked to a user, automatically or by the user; also when a provider sign-up creates the user | provider |
identity.unlinked | A user (or an administrator) removes a sign-in method | provider, by (self or admin) |
identity.signin_refused | A provider sign-in for an account that can’t sign in (the app is only told signin_refused) | provider, reason (disabled, network, erased, …) |
role.add, role.remove | A role is assigned or taken away, through the API or from realm.yaml seeds | role |
data.transfer, data.assign_org | A document is moved to another organization, or a job gives a collection’s documents without one an organization (admin data route) | database, collection, id, from, to / org, job_id; never content |
org.create, org.update | An organization is created (by a user or an administrator) or renamed | by, creator / fields |
org.member_add, org.member_remove, org.member_leave, org.roles | A member is added without an invitation (by is admin or sso), removed, leaves, or has roles changed | user, roles, by / added, removed |
org.team_create, org.team_update, org.team_delete, org.team_member_add, org.team_member_remove | A team is made, changed or deleted, or someone joins or leaves it | team, name, roles / user |
org.invitation_create, org.invitation_link, org.invitation_accept, org.invitation_revoke | An organization invitation is made, given a new link, accepted or revoked | invitation, binding, roles, teams, sent / user |
org.require_mfa | An organization turns its second-factor requirement on or off | value |
org.delete, org.deleted, org.reactivate | An organization is deleted (access ends), its cleanup ends, or it is brought back | by, job_id / counts (documents removed per collection; never content) / slug |
org.sso.set, org.sso.deleted, org.sso.disabled | An organization’s sign-in connection is set, removed, or switched off by backd because the manager who set it no longer holds the roles it gives on joining | provider, enabled, secret_changed / provider, by / provider, reason (never the secret) |
org.sso_required | An organization turns on or off the requirement to sign in through its connection, or backd turns it off because the last working connection went | value; turning it on without_sso, revoked; backd turning it off reason |
org.sso.login | A person signed in through an organization’s connection | user, provider, joined (this sign-in made them a member) |
org.claims, org.owner_assign | Claims are replaced; a manager is assigned | keys / user |
secret.key.create, secret.key.rotate, secret.key.delete | A field key is created, imported, rotated or deleted | fingerprint (the new one), generated or previous and rewrapped; never the key |
apikey.create, apikey.revoke | An API key is created or revoked | role, networks, expires_at |
invitation.create, invitation.revoke | An invitation is created or revoked | bound_to_email (whether it is), expires_at |
invitation.sent | An invitation is emailed to its address | expires_at |
passkey.registered | A user added a passkey | passkey, name, synced |
passkey.removed | A passkey was removed, by the user or an administrator | passkey, name, by (self or admin), sessions_ended |
admin.login | A user holding an admin role logs in | session_id, strategy, methods |
encrypt.start, encrypt.rekey, encrypt.migrate | An encryption migration was started, a field rekeyed, or a migration finished (by the job) | database, collection, fields, dry_run, job_id, generation; the counts and the number of retired keys; never a value |
data.reveal | A full administrator read documents that hold encrypted fields in plain through the admin data route (one record per read) | database, collection, fields, documents; never a value |
data.create, data.update, data.delete, data.restore, data.purge | A document is written through the admin data route (a batch audits each operation) | database, collection, id; the target is doc:<database>/<collection>/<id>; never content |
session.revoke | An administrator ends a user’s session (admin API) | session |
admin.refused | An admin API request is refused: by a network restriction, or because the administrator’s roles don’t open the area or hold the rights of the user or role involved | reason, method, path |
realm.bootstrap | backd-server bootstrap creates the realm’s first administrator | role |
Failed logins aren’t recorded here: they are throttled and counted, and every request is in the access log.
Records
{
"id": "d3cb1q0spovnshcrql9g",
"at": "2026-09-27T12:00:00.000Z",
"action": "role.add",
"actor": "user:d3c9ljp8hc2g00b6s1m0",
"target": "user:d3cb0m8spovnshcrqk90",
"details": { "role": "editor" },
"request_id": "d3cb1q0spovnshcrql8g",
"client_ip": "192.0.2.10"
}
actoris who acted:user:<id>(a session),key:<name>(an API key),anonymous(sign-ups and refused requests),config:realm.yaml(seeds applied at startup) orcli:bootstrap.targetis what the action applied to:user:<id>,key:<name>,invitation:<id>, or null.request_idmatches the request’sX-Request-IDand its line in the access log.client_ipfollowsTRUSTED_PROXIES.
Records name users by id, never by email, and never contain passwords or password hashes, session tokens, API keys, invitation tokens, or request bodies. A test checks this for every action.
Reading the trail
With the CLI, as an administrator:
backd audit --realm blog --since 7d
# TIME ACTION ACTOR TARGET CLIENT DETAILS
# 2026-09-27T12:00:00.000Z role.add user:d3c9ljp8hc2g00b6s1m0 user:d3cb0m8spovnshcrqk90 192.0.2.10 role=editor
# …
backd audit --realm blog --user bob@example.com # records about one user
backd audit --realm blog --action apikey.create --json
--action, --actor and --target filter exactly; --user looks up an existing user’s id (for a deleted user, use --target user:<id>); --since takes an RFC 3339 time or a duration back from now; --limit defaults to 50; --json prints one JSON record per line.
Over HTTP, GET /v1/{realm}/_admin/audit returns a page of records, newest first, with the query parameters action, actor, target, since, until, limit and skip (see the admin API). The JavaScript client has client.admin.audit.list(…).
Each record is also written to the log, as a line with "msg":"audit" and the same fields, so the trail reaches your log pipeline too. If a record can’t be stored, the action it describes has already happened; backd logs the record as an error ("msg":"audit record not stored") instead.
Retention
Records are kept for one year by default. Set another period per realm in realm.yaml:
audit:
retention: 180d # at least 1d
Each record’s expiry is fixed when it’s written, so a new retention applies to records written from then on. MongoDB removes expired records (a TTL index on expires_at).
Records hold client IP addresses and user ids, which are personal data in many jurisdictions: choose a retention your privacy policy covers, and include the trail in answers to access requests.
Append-only
- No endpoint or command changes or deletes records.
- In the production reference,
backd’s own database user may only read and insert into theauditcollection, so not even a compromisedbackdprocess can rewrite history. - Records are stored in the realm’s system database and included in its backups.
- The trail doesn’t cover direct access to MongoDB: people with database credentials can do anything. Keep those credentials to the provisioning step, as the production reference does.