Veto keeps individual vetoes secret in the database, not in the app
If the client must receive a secret in order to hide it, it is not secret. Veto keeps individual vetoes behind Postgres row-level security, keeps that table out of the realtime publication, and reveals winners through a server-side database function — so Swift never holds other people's picks, even in the network tab.
The product is the secret
If people can see each other's vetoes early, the game changes into something worse than a group chat. You get anchoring, retaliation, and the loudest person's opinion propagating through everyone else's picks. The privacy isn't a feature bullet, it's the mechanic that makes the results mean anything.
Which means the threat model isn't an attacker. It's a curious friend with an iPhone and mild technical skill, in a group where everybody knows everybody. That's a low bar to clear in absolute terms and a high one in practice, because the person most motivated to peek is the one already inside the group with a valid session.
The standard approach, filtering on the client, fails immediately against that. If the app receives all vetoes and hides the ones that aren't yours, then the secret was already transmitted to a device you don't control, and the hiding is a UI convention. Nothing about it survives someone looking at the traffic.
Row-level security is necessary and not sufficient
The first layer is the obvious one. Vetoes live in their own table with row-level security enabled and a select policy that returns only rows whose owner matches the current authenticated user. Query the table as anyone else and you get nothing back. Not an error, just no rows, which is the right shape because absence leaks less than a denial does.
Insert is similarly constrained so you can only write a veto attributed to yourself, and update and delete are restricted to your own rows and only while the round is still open. Once a round is revealed, the rows stop being mutable, so nobody can quietly revise history after seeing the outcome.
This is the part most teams get right, and it's also the part that creates false confidence. RLS governs what a query returns. It says nothing about what else the database might send you through a different channel, and in a realtime app there is always a different channel.
-- A veto is readable only by the person who cast it.
create policy "own vetoes only" on vetoes
for select using (auth.uid() = voter_id);
-- RLS is not enough on its own: rows in the realtime publication
-- still go out over the wire. The table must be left out of it.
alter publication supabase_realtime drop table vetoes;
-- The winner is computed server-side and returned as a result,
-- so no client is ever handed the votes it was derived from.
create function reveal_round(round uuid) returns table (option_id uuid)
language sql security definer as $$
select o.id from options o
left join vetoes v on v.option_id = o.id
where o.round_id = round
group by o.id
order by count(v.id) asc, random()
limit 1;
$$;The realtime publication is a second door
Veto uses Supabase realtime so a round updates on everyone's screen as people participate. Realtime works by replicating changes from a Postgres publication out to subscribed clients, and the set of tables in that publication is a separate decision from your RLS policies.
So the vetoes table is deliberately not in the realtime publication. Not filtered, not policy-guarded on the stream, simply absent from it. Changes to that table never enter the replication path at all, which means there is no code path, correct or buggy, that could deliver one person's veto to another person's socket.
What is published is the round's aggregate state: which participants have submitted, how many have finished, whether the round is open or revealed. That's everything the UI needs to show a live sense of progress, and none of it is the secret. The general rule we took from this: enumerate every channel data can leave the database through, and check each one separately. RLS covers queries. Publications, database functions, views, and logs are all their own decisions, and a view over a protected table is the classic way people accidentally reopen the door they just closed.

Reveal is a database function
When a round completes, the client doesn't fetch the vetoes and compute the winner. It calls reveal_round(), a Postgres function that reads the vetoes server-side, tallies them, picks the option with the fewest, and writes the result to the round.
The client never sees the raw vetoes even at reveal time. It sees a winner. Which options were vetoed by which people is information the app has no reason to hold, so it never receives it. That falls out naturally once the tally lives in the database, and it would have been extra work to arrange any other way.
Ties are broken randomly, and that randomness has to be server-side for the same reason the tally is. A client-side tiebreak is a client-side decision, and a client-side decision can be replayed until it produces a preferred answer. Inside the function it happens once, in one place, and the result is written before anyone is told what it was. The function is also the only thing permitted to flip a round into the revealed state, so there's a single transactional moment where the round closes and the answer exists, rather than a sequence of client steps that could be interrupted halfway.
What this makes harder
Debugging, mostly. You can't inspect the interesting state from the client, which is exactly the property you wanted and also exactly what you miss when a round behaves oddly. Diagnosing means querying with elevated access in a context that's deliberately awkward to reach, and it should stay awkward.
Testing has to move too. The tests that matter here aren't UI tests, they're database tests that assume a specific user's identity and assert that a query returns nothing. Those are the tests that fail when someone adds a well-meaning view or a convenience endpoint six months from now, and they're worth writing before the feature rather than after the leak.
The tradeoff we'd defend anyway is where the logic lives. Putting the tally in a Postgres function means some of the game's rules are written in SQL rather than Swift, which is a real cost in a codebase where everything else is Swift. But the rule being enforced is a security property, and security properties belong on the side of the boundary you control. The client is not that side.
Questions
- Why isn't filtering vetoes in the iOS client enough?
- Anything delivered to the device can be inspected. A curious participant with a valid session is the real threat model. Client-side hiding is a UI convention once the rows have already been transmitted.
- How do RLS and realtime publications interact for secrets?
- RLS governs query results; realtime is a separate door via Postgres publications. Keep secret tables out of the publication entirely. Publish only aggregates the UI needs — who has submitted, whether the round is open — never the veto rows.
- Why tally the winner in a database function?
- reveal_round() reads vetoes server-side, counts them, breaks ties once, and writes the result. Clients receive a winner, not raw vetoes. Client-side tiebreaks can be replayed until they produce a preferred answer.