KDB knowledge

Audit log

KDB writes security events (logins, users, DDL, backups, refused rights) and, for chosen tables, every changed row with who, when, old and new values.

The audit log is a table named _audit_log. KDB writes it; nobody can change, delete or drop its rows (it is append-only). It goes through the log like any change, so it survives crashes, and backups hold it.

WhatWhereTurn it on
logins (good, failed, lockouts), user and grant changes, CREATE/DROP/ALTER of tables, indexes, views, functions, procedures, triggers and databases, backups, timers, pinned statements, partitions a TTL dropped, refused rights_audit_log of the hidden _system databasekdb serve --data-dir ... (default --audit on)
every row a statement adds, changes or deletes in one table_audit_log of that databaseALTER TABLE t SET AUDIT ON

Columns of _audit_log: time, event, user, client, db, object, detail, row_key, old_values (JSON), new_values (JSON), session, ok, seq, hash, agent.

client is the address and protocol (10.0.0.5 (http)). agent is the program that sent the request: the HTTP User-Agent header (curl/8.4.0, the browser of the web console), or the PostgreSQL application_name (psql, DBeaver). It is at most 200 characters, and NULL when the client sent none. A client can write anything there, so it helps to tell programs apart but proves nothing.

An action done with the server's --token is written with user = token, or the name given with --token-name (or KDB_TOKEN_NAME), never with an empty user.

eventMeaning
login, login_failed, lockouta login, a wrong password or code, an address that must wait
user, ddl, protection, backup, serveruser statements, schema changes, protection settings, backups, server start, stop and restart
timera timer saved, run now, turned on or off, or deleted
pina pinned statement pinned or unpinned
ttl_droppartitions a table's TTL dropped by itself
denieda statement the user's rights did not allow
insert, update, deletea row of a table with SET AUDIT ON

SHOW AUDIT

Syntax:

SHOW AUDIT [LIMIT n] [WHERE <condition>] [LIMIT n]

The newest security events first (100 when there is no LIMIT). The condition uses the columns above.

Example: Failed logins of the last day

SHOW AUDIT WHERE event = 'login_failed' AND time > dateadd('d', -1, now()) LIMIT 20;

Result:

time (TIMESTAMP)event (STRING)user (STRING)client (STRING)db (STRING)object (STRING)detail (STRING)row_key (STRING)old_values (JSON)new_values (JSON)session (LONG)ok (BOOL)seq (LONG)hash (STRING)agent (STRING)

(0 rows)

no audit events yet

ALTER TABLE ... SET AUDIT ON

Syntax:

ALTER TABLE <name> SET AUDIT ON
ALTER TABLE <name> SET AUDIT OFF

From now on, every row a statement adds, changes or deletes in the table is also written to _audit_log of the database, in the same transaction as the change: there is never a change without its audit row, or an audit row without its change.

ColumnHolds
eventinsert, update or delete
user, client, agent, sessionwho: the logged-in user, the address and protocol, the client program, the session
detailthe statement (a big INSERT is shortened)
row_keythe key of the row: id='5', or rowid=7 for a table without a key
old_values, new_valuesthe row before and after, as JSON. Columns whose name holds password, secret, token, scram or totp show "***".

Example: Audit an accounts table

CREATE TABLE accounts (id INT PRIMARY KEY, owner STRING, balance DECIMAL(2));
ALTER TABLE accounts SET AUDIT ON;
INSERT INTO accounts VALUES (1, 'Sita', 100.00);
UPDATE accounts SET balance = balance - 25 WHERE id = 1;
SELECT event, row_key, old_values, new_values FROM _audit_log ORDER BY time;

Result:

CREATE TABLE accounts (

table 'accounts' created

ALTER TABLE accounts SET AUDIT ON

audit is on for table 'accounts': every changed row is written to _audit_log

INSERT INTO accounts VALUES (1, 'Sita', 100.00)

1 row(s) inserted

UPDATE accounts SET balance = balance - 25 WHERE id = 1

1 row(s) updated

SELECT event, row_key, old_values, new_values FROM _audit_log ORDER BY time

event (STRING)row_key (STRING)old_values (JSON)new_values (JSON)
insertid='1'NULL{"id":1,"owner":"Sita","balance":100.00}
updateid='1'{"id":1,"owner":"Sita","balance":100.00}{"id":1,"owner":"Sita","balance":75.00}

(2 rows)

Example: Nobody can change the audit rows (shows an error)

CREATE TABLE accounts (id INT PRIMARY KEY, balance DECIMAL(2));
ALTER TABLE accounts SET AUDIT ON;
DELETE FROM _audit_log;

Result:

CREATE TABLE accounts (

table 'accounts' created

ALTER TABLE accounts SET AUDIT ON

audit is on for table 'accounts': every changed row is written to _audit_log

Error: the audit table _audit_log is append-only: KDB writes it, nobody can change or drop it (old rows are removed by the audit retention setting)

The hash chain

Every row gets a number and a hash, so a row changed or removed outside KDB (by editing the files) is found.

ColumnHolds
seq1, 2, 3 ... in the order the rows were written
hashSHA-256 (hex) of the previous row's hash and this row's values

A changed row no longer matches its hash. A removed row leaves a gap in seq. SHOW AUDIT VERIFY, kdb check <database> and POST /api/check check the whole chain and name the rows that do not fit.

Example: The chain of an audited table

CREATE TABLE ledger (id INT PRIMARY KEY, amount DECIMAL(2));
ALTER TABLE ledger SET AUDIT ON;
INSERT INTO ledger VALUES (1, 10.00), (2, 20.00);
SELECT seq, event, row_key, length(hash) AS hash_length FROM _audit_log ORDER BY seq;

Result:

CREATE TABLE ledger (

table 'ledger' created

ALTER TABLE ledger SET AUDIT ON

audit is on for table 'ledger': every changed row is written to _audit_log

INSERT INTO ledger VALUES (1, 10.00), (2, 20.00)

2 row(s) inserted

SELECT seq, event, row_key, length(hash) AS hash_length FROM _audit_log ORDER BY seq

seq (LONG)event (STRING)row_key (STRING)hash_length (LONG)
1insertid='1'64
2insertid='2'64

(2 rows)

SHOW AUDIT VERIFY

Syntax:

SHOW AUDIT VERIFY

Checks the hash chain from SQL, the same check as kdb check, without stopping the server.

ColumnHolds
logwhich _audit_log: _system (the security events), or the database of the session (its SET AUDIT ON rows)
oktrue when every row matches its hash and no seq is missing
rows_checkedthe rows with a seq
unchainedolder rows written before the chain existed
first_seq, last_seqthe range of seq checked
first_brokenthe seq of the first row that does not fit; NULL when all fit
problemswhat is wrong, row by row
last_hashthe newest hash: note it somewhere else to also catch a cut at the end

Example: Check the chain of an audited table

CREATE TABLE ledger (id INT PRIMARY KEY, amount DECIMAL(2));
ALTER TABLE ledger SET AUDIT ON;
INSERT INTO ledger VALUES (1, 10.00), (2, 20.00);
UPDATE ledger SET amount = 15.00 WHERE id = 1;
SHOW AUDIT VERIFY;

Result:

CREATE TABLE ledger (

table 'ledger' created

ALTER TABLE ledger SET AUDIT ON

audit is on for table 'ledger': every changed row is written to _audit_log

INSERT INTO ledger VALUES (1, 10.00), (2, 20.00)

2 row(s) inserted

UPDATE ledger SET amount = 15.00 WHERE id = 1

1 row(s) updated

SHOW AUDIT VERIFY

log (STRING)ok (BOOL)rows_checked (LONG)unchained (LONG)first_seq (LONG)last_seq (LONG)first_broken (LONG)problems (STRING)last_hash (STRING)
demotrue3013NULLNULL4f8bf0605dba55930de5b81641faa09cf33287adde9c68e299bcae5d37ea3930

(1 row)

the audit hash chain is intact (3 rows checked)

GET /api/audit

Syntax:

GET /api/audit?limit=100&where=<condition>
GET /api/audit?db=<name>&kind=data&limit=100&where=<condition>

The same as SHOW AUDIT over HTTP. With kind=data: the data-change rows of the database db.

HTTP: The newest security events

GET /api/audit?limit=5

Response:

HTTP 200 (application/json)

{
  "query": "SHOW AUDIT LIMIT 5",
  "columns": [
    {"name": "time", "type": "TIMESTAMP"},
    {"name": "event", "type": "STRING"},
    {"name": "user", "type": "STRING"},
    {"name": "client", "type": "STRING"},
    {"name": "db", "type": "STRING"},
    {"name": "object", "type": "STRING"},
    {"name": "detail", "type": "STRING"},
    {"name": "row_key", "type": "STRING"},
    {"name": "old_values", "type": "JSON"},
    {"name": "new_values", "type": "JSON"},
    {"name": "session", "type": "LONG"},
    {"name": "ok", "type": "BOOL"},
    {"name": "seq", "type": "LONG"},
    {"name": "hash", "type": "STRING"},
    {"name": "agent", "type": "STRING"}
  ],
  "dataset": [],
  "count": 0
}

--audit-keep-days

Syntax:

kdb serve [--audit on|off] [--audit-keep-days N]

--audit off writes no security events. --audit-keep-days N removes security events older than N days, a whole month at a time, once a day (0 = keep forever, the default).

Erase one user's data

A person asks you to delete everything you hold about them. Three steps: find the tables, delete the rows, and know what the audit log keeps.

1. Find every table with a user column. information_schema.columns lists the columns of every table.

Example: Which tables hold a user id or an email?

Setup:

CREATE TABLE app_users (id INT PRIMARY KEY, email STRING NOT NULL);
CREATE TABLE orders (id INT PRIMARY KEY, user_id INT REFERENCES app_users(id) ON DELETE CASCADE, amount DECIMAL(2));
CREATE TABLE page_views (time TIMESTAMP, user_id INT, page STRING) TIMESERIES(time) PARTITION BY DAY;
SELECT table_name, column_name FROM information_schema.columns
WHERE column_name IN ('user_id', 'email') ORDER BY table_name;

Result:

table_name (STRING)column_name (STRING)
app_usersemail
ordersuser_id
page_viewsuser_id

(3 rows)

2. Delete the rows in one transaction. Tables that point to the user with REFERENCES ... ON DELETE CASCADE lose their rows together with the user's row (REFERENCES). Tables without such a key (a time-series table of page views, for example) need their own DELETE. One BEGIN ... COMMIT makes it all or nothing.

Example: Delete user 1 everywhere, then check

Setup:

CREATE TABLE app_users (id INT PRIMARY KEY, email STRING NOT NULL);
CREATE TABLE orders (id INT PRIMARY KEY, user_id INT REFERENCES app_users(id) ON DELETE CASCADE, amount DECIMAL(2));
CREATE TABLE page_views (time TIMESTAMP, user_id INT, page STRING) TIMESERIES(time) PARTITION BY DAY;
INSERT INTO app_users VALUES (1, 'ram@example.com'), (2, 'sita@example.com');
INSERT INTO orders VALUES (10, 1, 250.00), (11, 2, 99.50), (12, 1, 10.00);
INSERT INTO page_views VALUES ('2026-09-24T10:00:00Z', 1, '/home'), ('2026-09-24T10:01:00Z', 2, '/home');
BEGIN;
DELETE FROM page_views WHERE user_id = 1;
DELETE FROM app_users WHERE id = 1;
COMMIT;
SELECT 'orders' AS table_name, count(*) AS rows_left FROM orders WHERE user_id = 1
UNION ALL SELECT 'page_views', count(*) FROM page_views WHERE user_id = 1
UNION ALL SELECT 'app_users', count(*) FROM app_users WHERE id = 1;

Result:

BEGIN

transaction started

DELETE FROM page_views WHERE user_id = 1

1 row(s) deleted

DELETE FROM app_users WHERE id = 1

1 row(s) deleted

COMMIT

transaction committed: 4 change(s) saved

SELECT 'orders' AS table_name, count(*) AS rows_left FROM orders WHERE user_id = 1 UNION ALL SELECT

table_name (STRING)rows_left (LONG)
orders0
page_views0
app_users0

(3 rows)

3. What the audit log keeps. If a table has SET AUDIT ON, its _audit_log row for the DELETE holds the deleted row in old_values, and the older insert and update rows hold it too. These rows cannot be erased with SQL. The log is append-only on purpose: DELETE, UPDATE, TRUNCATE and DROP of _audit_log are refused, and every row is linked to the one before by the hash chain, so a row removed by editing the files is reported by kdb check. --audit-keep-days does not reach these rows either: it only removes security events.

Example: The deleted row is still in old_values

Setup:

CREATE TABLE app_users (id INT PRIMARY KEY, email STRING NOT NULL);
ALTER TABLE app_users SET AUDIT ON;
INSERT INTO app_users VALUES (1, 'ram@example.com');
DELETE FROM app_users WHERE id = 1;
SELECT event, row_key, old_values FROM _audit_log WHERE event = 'delete';

Result:

DELETE FROM app_users WHERE id = 1

1 row(s) deleted

SELECT event, row_key, old_values FROM _audit_log WHERE event = 'delete'

event (STRING)row_key (STRING)old_values (JSON)
deleteid='1'{"id":1,"email":"ram@example.com"}

(1 row)

What you can doHow
Keep personal data out of audited tablesPut names, emails and phone numbers in a table without SET AUDIT ON, and audit the table of money or orders that holds only the user's id. The audit rows then hold an id that means nothing once the user row is gone.
Hide secretsColumns whose name holds password, secret, token, scram or totp are always written as "***".
Stop recordingALTER TABLE t SET AUDIT OFF writes no new rows. The rows already there stay.
Remove everythingDROP DATABASE, then PURGE TRASH ALL, removes the database and its _audit_log. There is no way to remove only some rows.