Skip to content

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.

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.

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, rewrap and rotate, as those operations;
  • renaming, deleting and restoring a key, as rename_key, delete_key and restore_key;
  • creating a key (POST /v1/keys), as create_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.

These leave no row:

  • Reads: every GET, such as listing keys, reading a key, an audit log or usage, and GET /v1/me.
  • Billing requests.
  • 401 unauthorized, whatever the cause.
  • 429 rate_limited. (The Free (free) tier's 429 ops_cap_reached is recorded.)
  • 403 ip_not_allowed from a firewall.
  • 403 api_key_required, 403 session_required and 403 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. Also 405 method_not_allowed.
  • 503 unavailable when 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 failed create_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 different 413 payload_too_large, which is recorded.
  • A 500 internal_error from 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.

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.

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. decrypt and rewrap: the ciphertext's. wrap: the data key's, 32 when rypt generates it (on 400 invalid_dek_length, the wrong length you sent). unwrap: the wrapped key's. aad is never counted.
  • It is null when the request failed before that input was decoded (invalid base64 in any field, or an aad over its own limit), on 409 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 records bytes.

kms_version:

  • create_key and rotate: the new primary version.
  • encrypt, wrap and rewrap: the version the new ciphertext or wrapped key was made under.
  • decrypt and unwrap: the primary version, if the ciphertext was made under it; otherwise null. An older version is never named: after a rotation from "2" to "3", decrypting ciphertext made under "2" records null. 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.
  • null on every error row, and on rename_key, delete_key, restore_key and destroy_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_id set: an API key.
  • user_id set: a dashboard user.
  • Both null and src_ip null: rypt itself, destroying a key whose 7 days of pending deletion had passed.
  • Both null and src_ip set: a row written before the dashboard existed.

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.

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:00Z or 2026-09-16T14:00:00+02:00. A date alone, a time with no offset or a Unix time is refused with 400 invalid_query. Percent-encode a + as %2B, as --data-urlencode does: a bare + in a query string reads as a space.
  • A parameter that is present but empty, such as since=, is 400 invalid_query; leave it out instead. So is a parameter sent twice.
  • Parameters the endpoint does not know, such as offset, cursor or page, are ignored without an error.
  • since later than until is 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.

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, until cannot move past it. With limit=1000, that takes 1,000 entries on one key in one microsecond.
  • To put what you collected in order, sort by ts, then by id. Do not sort by id alone: ids follow insert order, which is not always ts order. rypt's own destroy_key row, for one, is inserted some time after its ts, so its id can be higher than those of entries with a later ts.
  • A pull is a snapshot of the window it read. To catch up later, pull again with since set some minutes before the newest ts you have, and drop duplicates by id. A since equal to that ts can miss a row that became readable after it, as when a row is written explains; rypt states no bound on that delay.

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/keys leaves 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.

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).

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."

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 the error_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 shows console (you) for you, console and a short id for another dashboard user, rypt, once the key was due for rypt's own destruction of the key, and before the console existed for 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.