Additional authenticated data (AAD)
Additional authenticated data (AAD) is data an AEAD cipher authenticates but never encrypts, so altering it breaks decryption even though it stays in the clear. Caisson's field-crypto module binds tenant id, key version, and column identity into every ciphertext's AAD, adding the row id as a fourth element on regulated fields so a relocated row fails to decrypt too.
In code
export function buildAad(
tenantId: string,
keyVersion: number,
columnContext: string,
rowId?: string,
): Buffer {
// Conditional construction: omitting `rowId` must yield the SAME bytes as the legacy 3-tuple —
// pushing `undefined` would serialize as `null` and break every existing ciphertext + golden.
const tuple =
rowId === undefined
? [tenantId, keyVersion, columnContext]
: [tenantId, keyVersion, columnContext, rowId];
return Buffer.from(JSON.stringify(tuple), "utf8");
}How it holds
Authenticated, never hidden
GCM authenticates the AAD bytes but does not encrypt them, buildAad's tenant/key-version/column tuple travels alongside the ciphertext in the clear. Tampering with any element, or moving the ciphertext under different metadata, makes the AEAD authentication tag fail to verify on decrypt.
Two honest paths: 3-tuple and row-bound 4-tuple
The transparent encryptedColumn Drizzle customType (column.ts) sees only the cell value, never the row's primary key, so it stays on the tenant/keyVersion/column 3-tuple with no cross-row tamper-evidence, for low-sensitivity fields only. encryptField/decryptField (encrypt-field.ts) require the caller to pass the row's stable crypto.randomUUID() PK as a fourth AAD element; SEC/HIPAA columns must use this row-bound path.
The rowId must be minted before the INSERT
encryptField's AAD is computed at encrypt time, before the row exists in the database, a DB-generated serial/identity PK is assigned only after the INSERT, too late to bind. encrypt-field.ts requires a client-minted crypto.randomUUID() PK instead, and assertRowId rejects a blank one up front rather than binding a degenerate identity.
A JSON tuple, not a delimiter-joined string
The AAD is JSON.stringify([tenantId, keyVersion, columnContext, rowId?]), JSON's own quoting and escaping separate the fields, so there's no delimiter for a crafted value to inject and no ambiguity about where one element ends and the next begins.