entity_id Was uuid, We Inserted a Token — 22P02
This article may contain affiliate links. Its content is not affected by advertising.
In short
When audit_logs.entity_id is a uuid column, a 64-hex token passed straight into an insert fails with 22P02, and a best-effort try/catch can drop the log entry without a trace.
Conclusion
An audit log’s entity_id column was typed uuid, but the caller passed a non-uuid string (a 64-character hex token) straight through. The insert failed with Postgres error 22P02 (invalid input syntax for type uuid). Because the audit-write path was wrapped in a best-effort try/catch, that failure vanished without a trace — and the record of an important operation went missing entirely.
Symptom
Issuing and revoking invite links for a private “loop” (a shared access scope) worked fine end to end. The invite email arrived, the link worked.
But the audit log showed zero entries for either the issue or the revoke. For an operation that’s effectively “mint or kill a capability URL that opens a private scope” — about as sensitive as it gets — there was no way to later answer who issued what, and when.
No error surfaced anywhere. The UI reported success, nothing showed up in exception logs. From the outside, there was no hint anything was wrong.
Cause
The audit table defines entity_id as uuid:
create table if not exists public.audit_logs (
id uuid primary key default gen_random_uuid(),
actor_id uuid references public.profiles(id) on delete set null,
actor_label text,
action text not null,
entity text not null,
entity_id uuid,
summary text not null,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
The code that logged invite issue/revoke events passed the invite token — a 64-character hex string — straight into entity_id:
await logAudit({
action: "create",
entity: "share_links",
entityId: issued.invite.token,
// ...
});
A 64-hex string doesn’t match uuid’s 8-4-4-4-12 hyphenated shape, so Postgres rejects the insert. The error code is 22P02 (invalid input syntax for type uuid) — Postgres’s generic response to “you gave a uuid column a string it can’t parse as a uuid.”
Here’s where it turned invisible: the logging function, logAudit, wrapped the whole thing in a try/catch:
export async function logAudit(input: AuditInput): Promise<void> {
try {
// ...
await supabase.from("audit_logs").insert({
// ...
entity_id: input.entityId ?? null,
// ...
});
} catch {
// don't let a logging failure affect the main operation
}
}
The catch block is a comment and nothing else — no rethrow, no log line. That reflects a reasonable best-effort design decision: a failed audit write shouldn’t block the real operation of issuing an invite. The side effect, though, is that the 22P02 failure became completely invisible to both developers and operators. The main operation succeeded, the UI reported success, no exception surfaced anywhere. The only casualty was a single row in the audit log — about the hardest kind of failure to notice.
The fix
entity_id now carries the uuid of the loop itself — the resource being acted on — instead of the invite token:
await logAudit({
action: "create",
entity: "share_links",
// audit_logs.entity_id is uuid. A 64-hex token fails the insert with 22P02
// and gets swallowed by logAudit's catch, so we log the loop's id instead.
// We also never store the full token — anyone who can read the audit log
// would otherwise hold a working invite key.
entityId: loopId,
// ...
metadata: {
loop_id: loopId,
company_id: companyId,
reused: issued.reused,
emailSent,
token_prefix: issued.invite.token.slice(0, 8),
},
});
Rather than dropping the token from the record entirely, only its first 8 characters are kept, under metadata.token_prefix. That’s not just a compromise for the sake of one — it sidesteps a second problem at the same time: storing the full token anywhere readable by everyone with audit-log access would hand them a working invite key. Fixing the type mismatch and narrowing what gets recorded happened together.
The revoke path got the same treatment: entityId now carries loopId instead of the token being revoked.
Preventing a repeat
What this incident actually shows is that “make audit logging best-effort” and “verify the audit log is actually writing” are two separate decisions. Wrapping the real operation in a try/catch to protect it is the right call. But when the catch is completely silent, the entire audit-logging mechanism can run for a long time looking like it works while recording nothing — and nobody notices.
The fix, as a policy going forward: entity_id always gets a table’s uuid primary key, never an application-level identifier like a token or email address. Leaving “which column feeds entity_id” to a case-by-case judgment call means the same 22P02 can resurface anywhere a new kind of audited operation only has a non-uuid identifier to work with.
Best-effort failure tolerance and silently swallowing failure aren’t the same thing. Even just writing the error to a structured log or forwarding it to monitoring inside the catch block would keep “don’t block the real operation” while making “someone can notice a missing record” possible. This fix didn’t add that output, so it’s still open.
Frequently asked questions
Q1Why didn't the insert failure reach anyone?
The audit-logging function wrapped the whole insert in a try/catch, and the catch block did nothing but leave a comment — no rethrow, no log output. That was a deliberate best-effort choice so a logging failure wouldn't block the real operation, but it also made the failure itself invisible.
Q2Why was a raw token passed as entity_id in the first place?
Right after issuing an invite link, the code needed a value that uniquely identified the event, and the 64-character hex token was what was on hand. It missed that audit_logs.entity_id is typed as uuid.
Q3Was the fix just to drop the token from the log?
No. entity_id now holds the uuid of the loop (the resource being acted on), and only the first 8 characters of the token are kept, under metadata.token_prefix. That side-steps a second problem too: anyone who can read the audit log would otherwise hold a working invite key.
Q4Does 22P02 show up for other reasons besides this one?
Yes. 22P02 is Postgres's generic error for any string it can't parse as a uuid. It isn't specific to entity_id — any uuid column fails the same way if it's handed a value in the wrong shape.
Environment verified
- Next.js Server Actions / Supabase (PostgreSQL) — kimiteras-portal
- Introduced 2026-06-12, caught in review and fixed 2026-07-24
What this article is based on
- TypeScript file lines 879-887commit e4985c0
- TypeScript file lines 882-899commit acadcbc
- TypeScript file lines 18-35commit e4985c0
- SQL file lines 3-13commit c8632cc
Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.