Read and retain the audit log
By the end of this guide, you can answer “who did what on this VTA, and when” from the audit log, and set how long those records are kept.
A VTA writes an audit entry for most privileged operations, such as ACL, key, vault, and backup changes. Each entry holds the actor’s DID, the action, the context, and the outcome. Entries are written on a best-effort basis, and some operations, such as renaming or deleting a context, are not recorded. This guide covers querying that record with pnm audit and setting its retention period. For what the VTA considers a privileged operation in the first place, see Request model.
Without a way to read it, the audit trail only helps after someone has already exported it somewhere else. Reading it directly is what turns questions like “which DID released that secret last Tuesday” into a single query, and it is usually the first thing asked after a credential is suspected of leaking.
Use this guide when:
- You need to establish what a particular DID did, and when.
- A credential may have leaked and you want to see what was done with it.
- An operation failed and you want the recorded outcome rather than a client-side guess.
- You need to set or confirm how long this VTA keeps audit records.
Prerequisites
A running VTA. See Quickstart.
pnminstalled and connected to the VTA.cargo install pnm-cli@0.23.1 --locked --registry crates-ioAdmin access. A context-scoped admin can read their own contexts; an unrestricted admin can read across all of them. Changing the retention period needs an unrestricted admin.
Read the log
pnm --vta <vta-slug> audit list --context-id my-app--context-id is required unless you are an unrestricted admin, which is the same scoping that applies everywhere else on a VTA. An empty result reports No audit log entries found.
Narrow the query
Each filter is an exact match, and they combine:
# What one DID did in a context (ACL changes carry no context, so a super-admin reads them without --context-id)
pnm --vta <vta-slug> audit list --context-id my-app --actor did:key:z6Mk...
# One kind of action, in a time window
pnm --vta <vta-slug> audit list --context-id my-app \
--action key.create \
--from 2026-09-01T00:00:00Z \
--to 2026-10-01T00:00:00Z
# Only the operations refused with a given outcome
pnm --vta <vta-slug> audit list --context-id my-app --outcome denied:permissionDenied| Filter | What it takes |
|---|---|
--from / --to | RFC 3339 timestamps. --from is inclusive, --to is exclusive. |
--action | An exact action name, such as acl.create or key.create. |
--actor | The DID that performed the operation. |
--outcome | An exact outcome, such as success or denied:permissionDenied. |
--context-id | The context to read. Required unless you are an unrestricted admin. audit list names this flag --context-id, while commands such as keys create use --context. |
Filters match exactly. A refused operation is usually recorded with an outcome of the form denied:<code>, such as denied:permissionDenied, so list the entries without --outcome first and copy the exact outcome you want to filter on. Filtering on refusals is the fastest way to find access that was attempted and refused, which is what you usually want after a credential is suspected of leaking.
Page through a large result
The default page size is 50, and a larger --page-size is capped at 200. When more entries match than fit on a page, the command prints a continuation cursor:
pnm --vta <vta-slug> audit list --context-id my-app --page-size 200
pnm --vta <vta-slug> audit list --context-id my-app --page-size 200 --cursor <cursor-from-previous-page>Pass the cursor back verbatim and keep every other filter identical. The cursor is bound to the filters and to your DID, so changing a filter mid-sweep, or reusing the cursor as a different DID, makes the VTA reject it with invalid pagination cursor.
Set how long entries are kept
# See the current period
pnm --vta <vta-slug> audit retention get
# Set it
pnm --vta <vta-slug> audit retention set --days 90audit retention get prints the current period:
Audit Retention
Retention period: 90 daysaudit retention set confirms the change:
✓ Audit retention updated to 90 daysRetention accepts 1 to 365 days. Choose it against whatever obligation you actually have to meet, and remember that the retention sweep picks up a new period after the VTA restarts. From then on it discards entries older than the new window.
Audit entries are excluded from pnm backup export by default, because they can be large. Add --include-audit when you export if the backup is meant to preserve them. See Back up and restore VTA state.
If you need records to outlive this VTA’s retention period, export them on a schedule and keep them in whatever system already holds your long-term logs.
Confirm
Test 1: a known action appears in the log
Mint a key, then look for it:
pnm --vta <vta-slug> keys create --key-type ed25519 --context my-app --label audit-check
pnm --vta <vta-slug> audit list --context-id my-app --action key.createThe entry for the key you just minted appears, naming your DID as the actor.
Test 2: a refused operation is recorded with a denied outcome
pnm --vta <vta-slug> audit list --context-id my-app --outcome denied:permissionDeniedRefused operations that change state, and refused vault operations, are recorded with a denied: outcome. A refusal appears under --context-id only when the request named that context, so as an unrestricted admin also run the query without --context-id. An empty result means nothing has been refused with that outcome in that context. List the context without --outcome to see every outcome recorded there.
Test 3: the retention period is what you set
pnm --vta <vta-slug> audit retention getThe period matches the value from “Set how long entries are kept”.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
audit list refuses without a context | --context-id is required for anyone who is not an unrestricted admin. | Pass --context-id <id>, or run as a super-admin to read across contexts. |
| A time filter returns nothing | --from and --to need RFC 3339 timestamps, and --to is exclusive. | Use the full form, 2026-09-01T00:00:00Z, and set --to past the last moment you want included. |
audit list --cursor fails with invalid pagination cursor | A filter changed between pages, or a different DID reused the cursor. | Restart from the first page with the filters you want, and keep them identical while paging. |
| An action you expected is missing | The action name is an exact match, or the entry predates the retention window. | Confirm the exact action name from an unfiltered listing, then check the retention period. |
| Audit entries missing after a restore | The backup was taken without --include-audit. | Re-export with --include-audit for future backups. Entries not captured cannot be recovered. |
Next steps
Glad to hear it! Please tell us how we can improve more.
Sorry to hear that. Please tell us how we can improve.
Thank you for sharing your feedback so we can improve your experience.