Audit log
rypt keeps an append-only audit log for each key. It records the encrypt, decrypt,
wrap, unwrap and rewrap calls on the key, failed ones included, and each change to
the key: its creation, renames, rotations, deletion, restore and destruction. A row
records what was done and its result, who acted, from which address and when, and for
those five calls the size of the input, never the input itself;
the entry format lists every field. You read the log one key at a
time, with GET /v1/keys/{id}/audit or on the key's Activity tab in the dashboard.
There is no bulk export;
a short script pages through one key's log into a file.
What is recorded
Section titled "What is recorded"Each of these operations writes one row, in the log of the key it acts on:
op |
Written for |
|---|---|
encrypt, decrypt, wrap, unwrap, rewrap |
Every call that finds the key, whatever the outcome: success, or any error from then on: 409 key_pending_deletion, 400 invalid_base64, 400 invalid_request (an empty plaintext), 400 invalid_dek_length, 413 payload_too_large (a field over its limit), 402 billing_required, 429 ops_cap_reached, 400 invalid_ciphertext, 500 internal_error. Also a call refused by the API key's key list or actions |
create_key |
A key created, in the new key's log. A create that fails once its body is read, one refused by the API key's key list or actions included, writes a row with no key id; a 503 unavailable from the check of its actions writes none |
rename_key |
A rename that changes the name, one that fails once the key is found (409 key_pending_deletion, 400 invalid_name, 409 key_exists), or one refused by the API key's key list or actions |
delete_key |
A key put into pending deletion, a delete that fails once the key is found, or one refused by the API key's key list or actions |
restore_key |
A restore, one that fails once the key is found (409 not_pending_deletion, 409 restore_window_closed), or one refused by the API key's key list or actions |
rotate |
A rotation, one that fails once the key is found (409 key_pending_deletion, 500 internal_error), or one refused by the API key's key list or actions |
destroy_key |
Delete now in the dashboard, or one that fails once the key is found; and rypt destroying the key after its 7 days of pending deletion |
Delete now first makes the key pending deletion and due, then destroys it. If destroying
it then fails, its row is an error, and the key stays pending deletion, refusing every
operation and every restore; a second Delete now, or rypt's own destruction, writes the
ok row. If rypt destroys the key while a Delete now is under way, the Delete now
succeeds but only rypt's row is written.
These also write rows, but with no key id: a create_key that failed; issuing API keys,
changing their firewall, key list or actions, and revoking them (create_api_key,
update_api_key, revoke_api_key); creating, renaming and deleting containers
(create_container, rename_container, delete_container); and creating, changing and
deleting firewalls (create_firewall, update_firewall, delete_firewall). They record
who acted and from where, but no endpoint and no dashboard page returns them.
Which refusals are recorded
Section titled "Which refusals are recorded"When an API key is refused by its key list or its actions (see
access control), the refusal is recorded as the operation that
was refused, with result error and the refusal as error_code. Each recorded
refusal is a row of its own. 403 key_not_allowed and 403 action_not_allowed are
recorded this way on:
encrypt,decrypt,wrap,unwrap,rewrapandrotate, as those operations;- renaming, deleting and restoring a key, as
rename_key,delete_keyandrestore_key; - creating a key (
POST /v1/keys), ascreate_key.
On every one of these but the create, the row goes in the log of the key named in the
path, and is written only when that id is a live key, pending deletion included, in the
API key's own container. A refusal for an id that names nothing, a destroyed key or a
key in another container writes nothing, so guessing ids cannot add rows to your log.
The 403 does not wait for the row: rypt checks the key and writes the row separately.
A read straight after the 403 may not show the row yet, and if writing it fails, the
row is lost: rypt does not retry it.
A refused create names no key: its create_key row has key_id null, so it is in no
key's log, and no endpoint or dashboard page returns it. rypt writes that row before it
sends the 403, and if writing it fails, the answer is 500 internal_error in place of
the 403. A create's body is read before the API key's key list and actions are checked
(see how a request is decided), so a
body error comes first, even for an API key that would be refused, and is not recorded.
403 key_not_allowed on GET /v1/keys/{id} and on GET /v1/keys/{id}/audit is not
recorded.
What is never recorded
Section titled "What is never recorded"These leave no row:
- Reads: every
GET, such as listing keys, reading a key, an audit log or usage, andGET /v1/me. - Billing requests.
401 unauthorized, whatever the cause.429 rate_limited. (The Free (free) tier's429 ops_cap_reachedis recorded.)403 ip_not_allowedfrom a firewall.403 api_key_required,403 session_requiredand403 no_tenant.- Key list and action refusals other than those listed above.
404 not_found: an id that is not a UUID, names nothing, or names a destroyed key or one in another container or tenant, and a path that is not a route. Also405 method_not_allowed.503 unavailablewhen rypt cannot check a credential or an API key's actions in time, and on a rename, delete, restore, rotation or Delete now that waited too long behind other key changes. (A create that waited too long is recorded as a failedcreate_key.)- A body refused before the key is looked up: malformed or wrong-shaped JSON
(
400 invalid_request), a missing or wrong content type (415 unsupported_media_type), or a body over 128 KiB (413 payload_too_large). A body under 128 KiB with a decoded field over its limit gets a different413 payload_too_large, which is recorded. - A
500 internal_errorfrom a failure before rypt has found the key, such as a database error. - Requests that change nothing: deleting a key already pending deletion, renaming a key to the name it has, revoking an API key already revoked, and an API key, container or firewall update that changes nothing.
- An operation whose own row could not be written; see when a row is written.
- In any row: plaintext, ciphertext, data keys,
aad, key names (a rename records neither the old name nor the new one), and an API key's name, prefix or secret.
When a row is written
Section titled "When a row is written"A request's row is inserted after the operation has taken effect, on its own. If that
insert fails, the request answers 500 internal_error even though the operation
happened, and there is no row for it. A cryptographic operation that ends this way is
still counted as an operation, and a create that ends this way leaves a key whose id you
were never sent. A failed operation whose row cannot be written also answers
500 internal_error, in place of its own error.
An error row does not prove that nothing happened either. A change whose database
commit is reported as failed, for example because the connection dropped, may still
have been committed, and is recorded as a failure; so is a rotation that fails after
the new version has already been made primary. After a 500 or an error row on a
create, rename, delete, restore or Delete now, read the key, or list your keys, before
you retry. After one on a rotation, see
if a rotation fails.
When rypt destroys a key after its 7 days of pending deletion, it writes the
destroy_key row in the same transaction that marks the key destroyed, so the key is
never marked destroyed without its row.
ts is the moment the insert began: just after the operation for a request, a moment
after the refusal for a recorded refusal, and, for rypt's own destroy_key, the start
of its transaction, before the calls that destroy the key's versions. A row can
therefore become readable after rows with a later ts.
The entry format
Section titled "The entry format"Each entry is a JSON object with exactly these 13 fields, in this order. A field with no
value is present as null, never left out.
| Field | Type | Null | Meaning |
|---|---|---|---|
id |
integer | No | The row's number. Unique, and increasing in insert order across all of rypt, so one key's ids have gaps. It does not always follow ts order. |
ts |
string | No | When the row was written: RFC 3339 in UTC, always with Z and exactly six fractional digits. Not unique. |
key_id |
string (UUID) | No, in any row you can read | The key the operation was on. |
api_key_id |
string (UUID) | Yes | The id of the API key the request presented; never its name, prefix or secret. null when a dashboard user or rypt itself acted, and on rows from before the dashboard. |
user_id |
string (UUID) | Yes | rypt's id for the dashboard user who acted (the User id on the dashboard's Settings page, not the Auth0 subject). null when an API key or rypt itself acted, and on rows from before the dashboard. |
op |
string | No | The operation, one of the names under what is recorded. |
kms_version |
string | Yes | The key version the operation used, as a short id such as "1", when known. |
result |
string | No | ok or error. |
error_code |
string | Yes | For error, the error code of the response the caller got, such as action_not_allowed. null when result is ok. |
request_id |
string | No | The request's x-request-id. |
src_ip |
string | Yes | The address the request came from, IPv4 or IPv6. null only on rypt's own destroy_key rows. |
bytes |
integer | Yes | The length in bytes of the operation's input. |
kms_us |
integer | Yes | Microseconds the operation spent in its key-management backend. |
tenant_id is not returned: you can only read your own tenant's rows.
This is the row an encrypt of hello world (11 bytes) writes:
{
"id": 48213,
"ts": "2026-10-14T09:12:03.418227Z",
"key_id": "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0",
"api_key_id": "7bd05772-cf91-4eea-86c4-e80d804610b2",
"user_id": null,
"op": "encrypt",
"kms_version": "1",
"result": "ok",
"error_code": null,
"request_id": "476baa74-6abd-4d80-bc4a-f82c441e87c8",
"src_ip": "203.0.113.7",
"bytes": 11,
"kms_us": 18342
}
bytes is set only on the five cryptographic operations:
encrypt: the decoded plaintext's length.decryptandrewrap: the ciphertext's.wrap: the data key's, 32 when rypt generates it (on400 invalid_dek_length, the wrong length you sent).unwrap: the wrapped key's.aadis never counted.- It is
nullwhen the request failed before that input was decoded (invalid base64 in any field, or anaadover its own limit), on409 key_pending_deletion, on a recorded refusal, and on every other operation. A Hardware key's combined limit is checked after the input is decoded, so a refusal for it recordsbytes.
kms_version:
create_keyandrotate: the new primary version.encrypt,wrapandrewrap: the version the new ciphertext or wrapped key was made under.decryptandunwrap: the primary version, if the ciphertext was made under it; otherwisenull. An older version is never named: after a rotation from"2"to"3", decrypting ciphertext made under"2"recordsnull. The primary named is the one rypt has on record, which after a rotation that failed part-way can be the previous version until the next rotation succeeds.nullon everyerrorrow, and onrename_key,delete_key,restore_keyanddestroy_key.
kms_us is set on the five cryptographic operations once they reach the backend: the
time of the backend call, or for rewrap of its calls added together, whether or not a
call succeeded. The backend is Cloud KMS for Software (software) and Hardware (hsm)
keys. For Free and Extra wrapped (wrapped) keys it is rypt's own wrapped-key code,
whose time includes a database read, and a Cloud KMS call when the version's data key is
not in rypt's cache, so
the same key can show very different times. It is not the request's latency. It is
null for an operation stopped before the backend (bad input, pending deletion,
402 billing_required, 429 ops_cap_reached, a recorded refusal), on every other
operation, and on rows written before rypt began recording it.
Who acted:
api_key_idset: an API key.user_idset: a dashboard user.- Both
nullandsrc_ipnull: rypt itself, destroying a key whose 7 days of pending deletion had passed. - Both
nullandsrc_ipset: a row written before the dashboard existed.
Source address and request id
Section titled "Source address and request id"src_ip is the address an API key's firewall is
checked against: the public address your request reached rypt's load balancer from,
whatever X-Forwarded-For or other headers you send. An IPv4 address carried as
IPv4-mapped IPv6 is written as IPv4.
request_id is the request's x-request-id: the same value as the response header and
an error body's request_id. You can set your own, to match rows to your own logs
(the request id gives the rules). A supplied id is
stored as sent, so it need not be unique and proves nothing. Keep personal information
out of it. rypt's own destroy_key rows carry a UUID that rypt made.
Reading a key's log
Section titled "Reading a key's log"curl -sS -G "https://api.rypt.dev/v1/keys/$KEY_ID/audit" \
-H "Authorization: Bearer $RYPT_TOKEN" \
--data-urlencode "since=2026-10-01T00:00:00Z" \
--data-urlencode "limit=1000"
The answer is a JSON array of entries, at most limit long, newest first: ordered by
ts, and by id among entries with the same ts, both descending. There is no
envelope, no total count and no link to a next page.
| Parameter | Meaning |
|---|---|
since |
Include entries with ts at or after this time. RFC 3339 with an offset. |
until |
Include entries with ts before this time; an entry at exactly until is left out. RFC 3339 with an offset. |
limit |
How many entries to return, 1 to 1,000. Default 100. |
- Times must be full RFC 3339 with an offset, such as
2026-09-16T12:00:00Zor2026-09-16T14:00:00+02:00. A date alone, a time with no offset or a Unix time is refused with400 invalid_query. Percent-encode a+as%2B, as--data-urlencodedoes: a bare+in a query string reads as a space. - A parameter that is present but empty, such as
since=, is400 invalid_query; leave it out instead. So is a parameter sent twice. - Parameters the endpoint does not know, such as
offset,cursororpage, are ignored without an error. sincelater thanuntilis not an error: the answer is[].
A bad value is refused with a message that names the parameter. This is the answer to
limit=0:
{
"error": "invalid_query",
"message": "limit must be an integer between 1 and 1000",
"request_id": "b400d79b-75d0-44a2-97b8-f6124a5bc4f8"
}
An API key reads the logs of keys in its own container. If it has a key list, an id
that is not on the list gets 403 key_not_allowed, which is not recorded. It needs no
action to read a log; see
what actions do not restrict. A
dashboard session reads every key's log. Any other id is 404 not_found, even with a
malformed since, until or limit.
A destroyed key's log stays readable by its id. GET /v1/keys does not list destroyed
keys, so keep the ids of keys whose history you may need. A destroyed key that had
successful operations in a month is still listed by id in that month's
usage report. Reading a log is not recorded
and is not counted as an operation, but each request uses the API key's
rate limit.
Reading more than one page
Section titled "Reading more than one page"There is no cursor. To read further back, send the same request with until set to one
microsecond after the oldest ts you received, then drop the entries you already have,
by id. Stop when a page holds fewer entries than limit.
Why one microsecond after: ts is not unique, and a page can end partway through the
entries of its oldest microsecond. until is exclusive, so until equal to that ts
skips the rest of that microsecond, entries you have not seen included. One microsecond
later reads the whole microsecond again, and the repeats are removed by id.
If the oldest entry of a page has ts 2026-10-14T09:12:05.733901Z, the next request
sends until=2026-10-14T09:12:05.733902Z. The seconds carry after .999999:
2026-10-14T09:12:05.999999Z becomes 2026-10-14T09:12:06.000000Z.
- If a whole page shares one microsecond,
untilcannot move past it. Withlimit=1000, that takes 1,000 entries on one key in one microsecond. - To put what you collected in order, sort by
ts, then byid. Do not sort byidalone: ids follow insert order, which is not alwaystsorder. rypt's owndestroy_keyrow, for one, is inserted some time after itsts, so itsidcan be higher than those of entries with a laterts. - A pull is a snapshot of the window it read. To catch up later, pull again with
sinceset some minutes before the newesttsyou have, and drop duplicates byid. Asinceequal to thattscan miss a row that became readable after it, as when a row is written explains; rypt states no bound on that delay.
There is no bulk export
Section titled "There is no bulk export"rypt has no export endpoint, no audit command in rypt.sh, no download in the dashboard, and no account-wide view of the log in the API or the dashboard. The Recent activity list on the dashboard's Overview is a sample, not a log: the 10 newest entries across up to five of the container's busiest keys this month.
The only way to read rows out is GET /v1/keys/{id}/audit, one key at a time, paging as
above. This script, rypt-audit-pull.sh, does that for one key and prints its whole log
as JSON Lines, one entry per line, oldest first. It needs bash, curl 7.76 or later (for
--fail-with-body) and jq.
#!/usr/bin/env bash
set -euo pipefail
key_id=${1:?usage: rypt-audit-pull.sh KEY_ID}
limit=1000 # the largest page the API returns
since=${SINCE:-} # optional inclusive lower bound, RFC 3339
until_ts="" # no upper bound on the first page
plus1us='
capture("^(?<s>.+)\\.(?<f>[0-9]{6})Z$") as $m
| ($m.f | tonumber + 1 | tostring) as $f
| if ($f | length) < 7
then "\($m.s).\("000000"[($f | length):])\($f)Z"
else ($m.s + "Z" | fromdateiso8601 + 1 | todateiso8601 | rtrimstr("Z"))
+ ".000000Z"
end' # a ts plus one microsecond, carrying after .999999
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
while :; do
args=(--data-urlencode "limit=$limit")
if [ -n "$since" ]; then args+=(--data-urlencode "since=$since"); fi
if [ -n "$until_ts" ]; then args+=(--data-urlencode "until=$until_ts"); fi
# On an error (429 rate_limited, 404 not_found, ...) print it and stop.
page=$(curl -sS --fail-with-body -G "https://api.rypt.dev/v1/keys/$key_id/audit" \
-H "Authorization: Bearer $RYPT_TOKEN" "${args[@]}") \
|| { printf '%s\n' "$page" >&2; exit 1; }
printf '%s\n' "$page" | jq -c '.[]' >> "$tmp"
# A short page means nothing older is left.
if [ "$(printf '%s\n' "$page" | jq length)" -lt "$limit" ]; then break; fi
# End the next window one microsecond after the oldest row of this page, so
# that whole microsecond is read again; the repeats are dropped below.
next=$(printf '%s\n' "$page" | jq -r ".[-1].ts | $plus1us")
if [ "$next" = "$until_ts" ]; then
echo "rypt-audit-pull: $limit events share the microsecond before" \
"$until_ts; cannot page past them" >&2
exit 1
fi
until_ts=$next
done
jq -s -c 'unique_by(.id) | sort_by(.ts, .id) | .[]' "$tmp" # drop repeats, oldest first
Make it executable and run it with RYPT_TOKEN set and a key id. Set SINCE to an
RFC 3339 time to read only entries from that time on.
./rypt-audit-pull.sh "$KEY_ID" > "$KEY_ID.jsonl"
SINCE=2026-10-01T00:00:00Z ./rypt-audit-pull.sh "$KEY_ID" > recent.jsonl
It reads one key per run. This runs it for every key the API key can see, one file per key:
curl -sS "https://api.rypt.dev/v1/keys" -H "Authorization: Bearer $RYPT_TOKEN" \
| jq -r '.[].id' \
| while read -r id; do ./rypt-audit-pull.sh "$id" > "$id.jsonl"; done
GET /v1/keysleaves out destroyed keys: run the script by hand for the destroyed keys whose ids you kept.- The script stops at the first error, such as
429 rate_limited, and prints it; it does not retry. Each page of 1,000 entries is one request. - It holds the key's whole log in memory for the final sort.
To add newer entries to a file you already have, pull from some minutes before its
newest ts (see reading more than one page for why) and
merge, dropping repeats by id:
SINCE=2026-10-14T09:00:00Z ./rypt-audit-pull.sh "$KEY_ID" > new.jsonl
cat "$KEY_ID.jsonl" new.jsonl \
| jq -s -c 'unique_by(.id) | sort_by(.ts, .id) | .[]' > merged.jsonl
Rows with no key id cannot be read this way, or any other way through the API: the rows for API key, container and firewall operations, and for failed key creation. The privacy policy says that a copy of your personal information, which you can ask for, includes these rows.
Append-only, and its limits
Section titled "Append-only, and its limits"A database trigger rejects every update and delete of an audit row, and rypt's code only inserts rows. Nothing in the API or the dashboard can change or remove a row.
What that does not cover:
- x6c LLC, which operates the database. The database's owner can disable the trigger, empty the table (the trigger does not fire on a truncate) or restore the database from a backup.
- Rows added: nothing checks what is inserted, so a compromised service could add false rows.
- Proof. Audit rows are not hash-chained or signed, and rypt publishes nothing you could check them against. The permission record of API key changes is a hash chain; the audit log is not. You cannot show from outside that the log is complete or unchanged. To notice a later change to rows you have already read, keep your own copy with the script above and compare later pulls with it.
- Completeness: some events are never recorded, and a failed insert leaves no row (when a row is written).
Retention
Section titled "Retention"Nothing in rypt deletes or expires audit rows, and there is no retention setting. Rows outlive the key they name: a destroyed key's log stays readable by its id. They also outlive revoked API keys and deleted containers and firewalls. For audit rows, with their IP addresses, the privacy policy states this retention: "For as long as the Service runs, including after keys are deleted and accounts are closed."
In the dashboard
Section titled "In the dashboard"Open a key from Encryption keys and choose its Activity tab. It reads the same endpoint and shows the newest entries first.
- Columns: Time (UTC, to the microsecond), Operation (
op), Result (ok, or theerror_code), Actor, Version (kms_version), Bytes, KMS time (kms_us), Source (src_ip) and Request id (the first 8 characters; Copy copies all of it). An empty field shows a dash. - Actor shows an API key by its name, as
API key "orders", or by a short id when the dashboard cannot name it, and marks a revoked one as revoked. It showsconsole (you)for you,consoleand a short id for another dashboard user,rypt, once the key was duefor rypt's own destruction of the key, andbefore the console existedfor rows from before the dashboard. - Range chooses All, Last hour, Last 24 hours, Last 7 days or Last 30 days. There is no other filter.
- Load older reads older entries 100 at a time, or 1,000 when a page of 100 falls in one microsecond. When none are left, the tab says "That is the whole log for this key."
- A destroyed key keeps its Activity tab; its other tabs are disabled.