Oliver White
10 May 2026 · 9 min read
Five sequential database queries in a vote handler create a TOCTOU race condition. Two requests arriving at the same millisecond both pass the duplicate-vote check, both insert a vote row, and one user gets counted twice. A single SECURITY DEFINER function eliminates this — one atomic transaction that cannot be interleaved with anything else.
It worked fine in testing. It would have worked fine in production — right up until two users clicked the vote button at the same millisecond.
Here's what the sequential approach actually looked like in Node.js:
The window between 'check for existing vote' and 'insert the vote' is a classic TOCTOU (time-of-check to time-of-use) race. Two requests arriving simultaneously both pass the duplicate check, both insert a vote row, and one user gets counted twice.
In practice, with a small user base, this race fires rarely. But 'rarely' is not 'never', and corrupted vote counts are exactly the kind of silent data integrity bug that's hard to detect and impossible to retroactively fix cleanly.
PostgreSQL has a feature that most application developers never reach for: SECURITY DEFINER functions. A function marked SECURITY DEFINER executes with the permissions of the function owner — not the calling user — and critically, runs as a single atomic database transaction. No other query can interleave with it.
Writing this function taught me something PostgreSQL does not make obvious. When your RETURNS TABLE declaration includes a column named vote_count, and your function body does UPDATE artworks ... RETURNING vote_count, PostgreSQL cannot tell whether you mean the table column or the output column. It throws ERROR 42702: column reference is ambiguous — and every single vote submission returns a 500.
Fix: alias the table in the UPDATE clause. `UPDATE artworks AS aw SET vote_count = aw.vote_count + 1 ... RETURNING aw.vote_count` — now PL/pgSQL has no ambiguity.
A single IP hash is not enough to prevent duplicate voting. VPNs reset IPs. Shared networks mean one IP covers hundreds of users. The function checks three independent vectors simultaneously:
| Layer | Key | Covers | Weakness |
|---|---|---|---|
| IP hash | hashIP(clientIP) + contest_id | Anonymous visitors, most casual cases | VPNs, shared networks (offices, universities) |
| User ID | auth.users.id + contest_id | Any signed-in user regardless of IP | Requires the user to have an account |
| Email hash | HMAC(email, VOTE_HASH_SALT) + contest_id | Users who sign in from different devices/IPs | Only catches authenticated users |
The email hash uses a separate VOTE_HASH_SALT environment variable — not the IP hash salt. This matters: if the same salt is used for both, a compromised salt exposes both hashing schemes simultaneously.
Before (5 queries)
~200ms
sequential round trips
After (1 RPC)
~40ms
single atomic transaction
Latency reduction
80%
p50 vote endpoint
Race condition risk
0
eliminated by atomicity
The improvement comes from two sources: fewer network round trips between the application server and database, and the query planner executing the entire check-and-write sequence inside a single transaction without interleaving I/O.
The RPC returns a typed row — success boolean, error_code string, vote_count integer. The application maps error codes to HTTP status codes explicitly, so every error case has a documented, testable response shape.
If you're writing sequential database queries inside a request handler, ask whether a single SECURITY DEFINER function could replace them. You'll get atomicity, performance, and a cleaner error model — all in one change.
Built with this methodology
Five sequential database queries in a vote handler create a TOCTOU race condition and ~200ms latency. A single SECURITY DEFINER function eliminates both — one atomic transaction, ~40ms, no interleaving possible.
From the build log