Using Badge
Audit log
What a workspace audit log records, who can see and export it, how to verify its hash chain and signed checkpoints yourself, and what Badge cannot guarantee.
The workspace audit log is an append-only record of changes to a team workspace: agents, members, invitations, certificates, and retention settings. Each entry is linked to the one before it by a hash, so a change to any past entry can be detected. Owners can export the log, verify the chain, list the signed checkpoints of its head, and create new ones.
Audit log export is included from the Team plan, and Enterprise has it too, as shown on Pricing. Single sign-on and SCIM are the Enterprise-only governance features.
The audit log is available only when it is switched on for your Badge deployment. While it is off, the audit log page and its endpoints are not available.
What is recorded
Each entry names the action, the target, and the actor.
| Action | Recorded when |
|---|---|
agent.created, agent.updated, agent.deleted | a workspace agent is created, changed, or removed |
agent.workspace_changed | a workspace agent's workspace-relevant settings change (the endpoint itself is recorded only as present or absent) |
agent.otlp_token_minted, agent.otlp_token_rotated, agent.otlp_token_revoked | the trace-ingest token of a workspace agent is created, replaced, or revoked (the token itself is never recorded) |
member.joined, member.removed, member.role_changed | a person joins or leaves the workspace, or their role changes |
invitation.created, invitation.resent, invitation.revoked | an invitation to the workspace is sent, sent again, or revoked |
certificate.issued | a certificate is issued for a workspace agent |
audit.retention_updated | the owner changes the retention period |
audit.exported | someone exports the log |
audit.checkpointed | a signed checkpoint is created |
audit.redacted | a single entry's details are removed |
audit.retention_applied | the retention period removes details from old entries |
audit.subject_erased | an account whose actions appear in the log is erased |
sso.config_updated | an owner changes the single sign-on settings, or turns single sign-on off |
sso.login_succeeded | a person signs in through single sign-on |
sso.login_failed | a single sign-on sign-in is refused. The entry holds a fixed reason, never the assertion, the email address or the identity provider's message. At most one entry a minute for each reason and workspace; the entry that follows a quiet minute carries a suppressed count |
sso.user_provisioned | a person's first single sign-on sign-in creates their account, or adds them to the workspace |
sso.domain_verified | a company domain claimed for single sign-on is proved with its DNS record |
sso.domain_claim_displaced | another workspace's claim on a domain that was not yet proved with DNS replaced this workspace's claim |
sso.identity_linked | an account that already existed is connected to the workspace's single sign-on. consent is true when the person confirmed it with their current password or GitHub sign-in (method), which every account with its own Badge credential must do. replaced and previous_provider mark a connection that moved to a new identity from the same workspace |
Each entry also records:
- Actor type:
user(a signed-in person),api_key(a request made with an API key on a person's behalf), orsystem(a scheduled job, account erasure, or a refused single sign-on attempt, which has no signed-in person yet). A refused single sign-on attempt is labelledsso:samlorsso:google. - Actor label: the actor's email address at the time of the action. Erasure replaces it.
- Target: the type and id of the thing changed, with a label where one is useful.
- Request id and IP address, where the request carried them.
- Before and after fingerprints for changes to agents and settings. An
agent.updatedentry is written only when something changed, and itsmetadata.changed_fieldsnames what changed. An agent's endpoint, credentials, manifest URL and payload schema are named there but their values are never logged.
Secrets are never written to the log. Tokens, passwords, API key values, and signing keys are refused at write time. Invitation tokens and accept links are never stored.
Not recorded. API key creation and revocation, and webhook creation and deletion, are not in the log. These are settings on your personal account, not on the workspace.
Who can see and export it
| Action | Owner | Admin | Member, Viewer |
|---|---|---|---|
| View the log | yes | yes | no |
| Export the log | yes | no | no |
| Verify the chain | yes | no | no |
| Create a signed checkpoint | yes | no | no |
| List signed checkpoints | yes | no | no |
| Remove details from one entry | yes | no | no |
| Set the retention period | yes | no | no |
How to read an export
Export from the audit log page as CSV or JSON Lines. An unfiltered export contains every entry in order of seq, starting at 1. An export follows the filters set on the page: action, actor_user_id, target_type, target_id, and a date range (from inclusive, to exclusive). It contains only the matching entries, so a filtered export has gaps in seq and cannot be chain-verified on its own. For an audit, export unfiltered as JSON Lines with checkpoints (include=checkpoints, or Download full log with checkpoints under Checkpoints on the page).
seqis the entry's position in the chain. Gaps mean missing entries.prev_hashis thehashof the previous entry. The first entry has none.hashis the entry's fingerprint. It covers these fifteen fields, in this order:seq, the workspace id,actor_type,actor_ref_hash,actor_display_hash,action,target_type,target_id,target_label_hash,before_hash,after_hash,metadata_hash,request_id,created_at, andprev_hash.actor_ref_hashis the SHA-256 of the actor's user id. It stays after an account is erased.actor_display_hashis the SHA-256 of the actor's label and IP address together.target_label_hashis the SHA-256 of the target label.metadatais the stored detail. A removed entry shows{"redacted":true,"redacted_at":...}here.metadata_hashis fixed when the entry is written and does not change when its details are removed.
The actor label, target label, IP address, the raw user id, and the metadata body are not part of hash directly. The hashes above cover them, which is why an erasure can replace them and the chain still verifies.
In CSV, a blank cell means no value. In a spreadsheet, a value that begins with =, +, -, or @ is prefixed with an apostrophe in the actor_label, target_label, and ip_address columns. The hashed columns are exported exactly as stored.
The export is the log as it stood when you pressed export. Entries written while the download runs are not included, and the export itself is recorded as an audit.exported entry.
How to verify the chain
Owners can press Verify chain on the audit log page, or call GET /teams/{team_id}/audit-log/verify. The check recalculates every hash from the stored fields and reports:
valid:trueonly when every check passed.reason: whenvalidisfalse, the first check that failed (see the table below).checked: how many entries were examined.first_invalid_seq: the entry or checkpoint the failure points at, if any.head_seqandhead_hash: the most recent entry, as recorded by the chain head.last_checkpoint: the most recent signed checkpoint, withsignature_status.checkpoints_checkedandcheckpoints_expected: how many signed checkpoints were found, and how many the chain head has counted. With reasoncheckpoints_missing, the difference is how many are gone, andfirst_invalid_seqis empty.
reason | What it means |
|---|---|
chain_broken | an entry is missing, out of order, or its hash does not match its fields |
metadata_mismatch | the stored details no longer match metadata_hash |
labels_mismatch | a stored label or IP address no longer matches its hash |
actor_mismatch | actor_ref_hash does not match the stored user id |
unjustified_redaction | details were removed with no matching record of why |
retention_forged | a retention record names an entry that is not older than the record's own cutoff, so it cannot be a real retention run |
target_invalid | a retention record does not name a valid entry: its target is not a plain entry number, or is past the end of the log. Badge ignores it when deciding what to remove |
timestamp_in_future | an entry is dated more than an hour ahead of Badge's own clock |
timestamp_out_of_order | an entry is dated more than an hour before an entry that comes earlier in the log |
unjustified_erasure | an account was cleared with no matching audit.subject_erased entry |
head_mismatch | the chain head disagrees with the last entry, so entries were removed from the end |
checkpoint_mismatch | a signed checkpoint does not match the entry at its position |
checkpoint_signature_invalid | a signed checkpoint's signature does not verify |
checkpoint_key_unknown | a signed checkpoint was made with a key Badge no longer holds (see below) |
checkpoints_missing | fewer signed checkpoints are stored than the chain head counts |
Verify an export yourself
To check an export's hash chain yourself, offline, use an unfiltered export and the following steps.
- Take the workspace id from the file name: the id at the end of
audit-log-<workspace name>-<date>-<team id>.jsonl(a file fetched straight from the API isaudit-log-<team id>.jsonl). No row contains it. - For the first entry,
prev_hashmust be empty. For every later entry,prev_hashmust equal the previous entry'shash. - For each entry, build a JSON object with exactly the fifteen fields listed above, using the stored values. Write it as JSON with sorted keys, no spaces, and non-ASCII characters left as they are. Encode it as UTF-8, and take its SHA-256. Empty values are JSON
null. The timestamp is written as2026-10-16T09:30:00.123456Z: UTC, six fractional digits, and a trailingZ. The result must equal the entry'shash. - Where
metadatais not a redaction marker, the SHA-256 of its sorted, compact JSON must equalmetadata_hash.
If the first entry fails step 3, the usual causes are the wrong workspace id or a timestamp without its six fractional digits.
Signed checkpoints
A checkpoint is a signature, made with Badge's verification key, over the chain head at one point in time. It records the entry number (seq), the hash at that position (head_hash), and the time it was signed.
- The owner can create a checkpoint on demand. Badge also creates one each day while the audit log is on and the background jobs are running. If nothing has been written since the last checkpoint, no new one is made.
GET /teams/{team_id}/audit-log/checkpointslists every checkpoint, oldest first.GET .../export?format=jsonl&include=checkpointsadds the checkpoints at or before the export's last entry to the end of the file, marked"record_type": "checkpoint".- The signed bytes are the ASCII text
badge-audit-checkpoint-v1, one zero byte, then the JSON object{"head_hash":...,"seq":...,"signed_at":...,"team_id":...}with sorted keys and no spaces. The signature is Ed25519, in base64url. The public key is published at/.well-known/badge-verification-key.json.
To check a checkpoint, confirm that the entry at its seq has hash equal to its head_hash, and that its signature verifies against the signed bytes above.
Before signing, Badge checks that the chain head matches the entry at its seq: the entry must carry that hash and still hash to it. If it does not, Badge refuses to sign and the request fails with 409 Conflict. Run verify to find the first invalid entry.
Key rotation. Badge holds one verification key. A checkpoint signed under a different key id is reported as key_rotated and makes the chain not intact, with reason checkpoint_key_unknown. Badge cannot yet verify old keys on its own. Save the public key with each checkpoint you rely on, and verify it against that saved key.
If Badge ever rotates that key before it supports old keys, the Verify banner on the audit log page reads "The checkpoint at event #N was signed with a key Badge no longer recognises" in amber, not "Tamper detected", and links here. That is a key change, not an edit to your events. Verify the checkpoint offline against the public key you saved with it. The same reason is returned by GET /teams/{team_id}/audit-log/verify.
Keep checkpoints outside Badge. A checkpoint you have saved elsewhere lets you prove later that the log had that head at that point. Save one before you rely on an export for an audit.
Retention and removal of details
An owner can set a retention period between 1 and 3650 days, or keep the log indefinitely. Once a day, entries older than the period have their details removed. The entry itself stays: its seq, actor type, action, target, and hashes are kept, so the chain still verifies. A removal is recorded as an audit.retention_applied entry.
An owner can also remove the details of a single entry. This is recorded as an audit.redacted entry that names the entry it changed. An entry's details can be removed only once.
Entries are never deleted while the workspace exists.
Account erasure
When a person's account is erased, their user id is removed from every entry they performed, and their email address and IP address are replaced in the log. Their anonymous fingerprint stays, so the log still shows that one actor, now anonymous, did these things. Each affected workspace receives an audit.subject_erased entry.
What Badge cannot guarantee
- Tampering by someone with direct database access. The database refuses changes to the hashed fields and refuses deletion of entries. A database administrator can still turn that protection off. Signed checkpoints are the evidence against that, and they are only as strong as where you keep them. Save a checkpoint outside Badge if you rely on the log for an audit.
- Removal of the most recent entries. Verification detects a gap at the end of the log only when the chain head and the entries disagree. Entries removed after the last checkpoint, with the chain head changed to match, are not detected, because no checkpoint covers them. Compare
head_seqwith theseqof the latest checkpoint you hold. - Forged removal records. Someone with write access to the database can add a well-formed record claiming that details were removed. The two kinds are bounded differently, and both need that write access.
- A forged retention record covers many entries at once, so verification limits it. It can cover only entries older than the cutoff it carries, that cutoff must sit at least one day (the shortest retention period) before the record, and the record's own time cannot be ahead of Badge's clock or well behind the entries before it (an hour of leeway). Anything else is reported as
retention_forged,target_invalid,timestamp_in_futureortimestamp_out_of_order. In practice it can blank only entries that are about a day old or older. - A forged redaction record has no age limit. It blanks exactly the one entry it names, whatever its age, and it appears in the log as an
audit.redactedentry, one per blanked entry. - In both cases the hashed fields of the blanked entries are unchanged, so the log still shows that something was removed and when.
- A forged retention record covers many entries at once, so verification limits it. It can cover only entries older than the cutoff it carries, that cutoff must sit at least one day (the shortest retention period) before the record, and the record's own time cannot be ahead of Badge's clock or well behind the entries before it (an hour of leeway). Anything else is reported as
- Forged entries of any kind. The same person can add any well-formed entry, not only removal records, such as a role change that never happened. Verification cannot tell it from a real entry. The chain proves the order of entries and that they have not changed. It does not prove who wrote them. A checkpoint you saved earlier proves only that the entry was not there when the checkpoint was signed.
- A rewritten log with a fresh signature. Badge signs the chain head the database holds. It refuses a head that does not match the entry at its
seq, but it does not re-check the whole chain first. A database administrator who rewrites every entry consistently, deletes the old checkpoints, and then asks for a new one gets a valid signature, and verification passes. Only checkpoints you saved outside Badge before the rewrite expose it. - Restores from backup. Restoring a database to an earlier point rolls the log back with it. The chain still verifies, but entries written after the backup are gone. Record the head and checkpoint values before any restore.
- Activity before the log was switched on. Entries are written only while the audit log is on.
- Activity outside the list above. If an action is not in the table, it is not in the log.
- The contents of removed details. Once details are removed, they cannot be recovered from the log. Their fingerprints remain.