Before opening pollitics.es, my Spanish vote-intention thermometer, to the public, it had to go from "works on my machine" to "strangers will poke at this". Political polls attract exactly the kind of visitor you don't want: people who'd love to stuff the ballot, scrape someone's vote, or embed your results page somewhere it doesn't belong. So before the public launch I did one boring, very large commit called pre-launch security enhancements. A 461-line migration, a 537-line SQL test file, security headers and a handful of small fixes.
None of it is clever, and that's sort of the point. Here's the checklist, with the code, in the order I'd do it again.
1. Close Supabase's default grants
This is the one that surprised me most, so it goes first. Out of the box, Supabase grants privileges on everything in the public schema to anon and authenticated. RLS is what usually saves you, but "a new table is reachable unless I remember to protect it" is a bad default for a project where the next migration will be written at midnight.
So I flipped it. Anything the migration role (postgres) creates from now on starts with zero client access:
alter default privileges for role postgres in schema public
revoke all on tables from anon, authenticated;
alter default privileges for role postgres in schema public
revoke all on sequences from anon, authenticated;
alter default privileges for role postgres in schema public
revoke all on functions from anon, authenticated;
-- EXECUTE to PUBLIC is a global Postgres default: it can only be
-- revoked without a schema (this also covers the private schema).
alter default privileges for role postgres
revoke execute on functions from public;
-- Existing identity sequences: clients never insert, they don't need them.
revoke all on all sequences in schema public from anon, authenticated;
Two gotchas. Default privileges only apply to objects created afterwards, so existing explicit grants stay exactly as they were (which is what you want, and what the tests check). And a function used inside an RLS policy now needs its own grant execute, otherwise the policy fails for that role instead of quietly passing.
2. RLS policies that call one function, once
The product rule on pollitics is that only people who have voted see the full evolution of the results. Everyone else, anonymous visitors included, sees just the latest published snapshots. On top of that there's Spanish electoral law: during the pre-election blackout nobody can see new polls, so nothing published from the blackout's start date onwards is readable.
That rule touches eight aggregate tables. Instead of copy-pasting the logic into eight policies, there's one security definer function that returns the readable time range for whoever is asking:
create or replace function public.readable_snapshots()
returns tstzrange
language plpgsql
stable
security definer
set search_path = ''
as $$
declare
v_until timestamptz; -- exclusive upper bound; null = no limit
v_from timestamptz;
begin
if public.electoral_blackout_active(now()) then
select (s.value ->> 'from')::timestamptz into v_until
from public.app_settings s
where s.key = 'electoral_blackout';
end if;
if public.has_voted() then
return pg_catalog.tstzrange(null, v_until, '[)');
end if;
-- Non-voters: only the last two publishable snapshots.
select min(l.snapshot_at) into v_from
from (
select m.snapshot_at
from public.snapshot_meta m
where v_until is null or m.snapshot_at < v_until
order by m.snapshot_at desc
limit 2
) l;
if v_from is null then
return 'empty'::tstzrange;
end if;
return pg_catalog.tstzrange(v_from, v_until, '[)');
end;
$$;
And every policy is a one-liner:
alter policy "results_snapshots_read" on public.results_snapshots
using (snapshot_at <@ (select public.readable_snapshots()));
The details that matter:
- Wrap the call in
(select ...). Postgres then evaluates it once per query as a scalar subquery instead of once per row. Same result, very different bill on a big table. security definerplusset search_path = ''. The function readssnapshot_meta, which has its own policy that calls the same function. Running as the owner avoids that recursion, and the empty search path (with fully qualified names) stops anyone from shadowingpublic.has_votedwith their own object.- Why two snapshots and not one. Some pages read the latest
snapshot_atin one query (or from a 15-minute cache) and the rows in another. If the cron job writes a new snapshot in between, a "last one only" rule would make the page go blank. The second-to-last snapshot is the margin.
3. A CSP that doesn't kill ISR
The textbook answer is a nonce-based Content Security Policy. The catch in Next.js is that nonces are per request, so they force every page to render dynamically. The pollitics home page is ISR, and I'd rather keep it served from cache. So the CSP has no nonces and accepts 'unsafe-inline' for scripts, and everything else is locked down hard:
// next.config.ts
const supabase = process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
const turnstile = "https://challenges.cloudflare.com";
const isDev = process.env.NODE_ENV === "development";
const vercelLive = process.env.VERCEL_ENV === "preview" ? " https://vercel.live" : "";
const csp = [
"default-src 'self'",
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} ${turnstile}${vercelLive}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' blob: data:",
"font-src 'self'",
`connect-src 'self' ${supabase}${vercelLive}`,
`frame-src ${turnstile}${vercelLive}`,
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
...(isDev ? [] : ["upgrade-insecure-requests"]),
].join("; ");
const securityHeaders = [
{ key: "Content-Security-Policy", value: csp },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), browsing-topics=()" },
];
const nextConfig = {
poweredByHeader: false,
async headers() {
return [{ source: "/:path*", headers: securityHeaders }];
},
};
A few notes from getting it to actually work:
- The only external origins are Supabase (auth and RPC calls from the browser) and Cloudflare Turnstile (script plus iframe for the captcha). If you can't list your third parties on one hand, the CSP will tell you.
- Vercel's preview comment toolbar needs
vercel.live, so it's allowed only whenVERCEL_ENVispreview. frame-ancestors 'none'andX-Frame-Options: DENYsay the same thing for new and old browsers: nobody gets to frame the voting page inside something that looks official.- The CSP is built at build time from
NEXT_PUBLIC_SUPABASE_URL. Change the Supabase URL and you have to redeploy. I wrote that in the README because future me will forget. - No HSTS header in the config, because Vercel already sends it on every HTTPS domain.
4. An open-redirect-proof ?next=
Login flows love ?next=/somewhere. They also love becoming open redirects: ?next=//evil.com is the classic, but the fun ones abuse the fact that browsers strip tabs and newlines and treat \ like /. So /\t/evil.com can end up on evil.com. The fix is small and paranoid:
const BASE = "https://pollitics.invalid";
export function safeNext(next: string | string[] | undefined, fallback = "/intencion-de-voto#votar") {
const value = Array.isArray(next) ? next[0] : next;
if (!value || !value.startsWith("/") || /[\u0000-\u001f\u007f\\]/.test(value)) return fallback;
try {
const url = new URL(value, BASE);
if (url.origin !== BASE) return fallback;
return url.pathname + url.search + url.hash;
} catch {
return fallback;
}
}
Reject control characters and backslashes outright, then let the URL parser have the last word: resolve against a fake origin and make sure the origin didn't move. Use .invalid as the TLD so the base can never be a real site.
5. GDPR export and delete that go through the same doors
If you collect political opinions in the EU, "email us to delete your data" doesn't cut it. Both rights are self-service now.
Export (GDPR article 20, portability) is a plain route handler, /cuenta/exportar. It reads the user's data through the same get_my_* RPC functions the account page uses, so it can't see anything the user couldn't already see, and it returns a JSON attachment with Cache-Control: private, no-store so no CDN ever keeps a copy.
Delete is a Supabase Edge Function. Deleting the auth.users row cascades through an identity map and a trigger that removes the respondent, their vote and its history. One detail worth stealing: with Supabase's new API keys, the keys aren't JWTs, so the function runs with verify_jwt = false and validates the user's token itself:
Deno.serve(async (req) => {
if (req.method !== "POST") return json({ error: "method_not_allowed" }, 405);
const jwt = req.headers.get("Authorization")?.replace(/^Bearer\s+/i, "");
if (!jwt) return json({ error: "not_authenticated" }, 401);
const { data, error } = await admin.auth.getUser(jwt);
if (error || !data.user) return json({ error: "not_authenticated" }, 401);
const { error: deleteError } = await admin.auth.admin.deleteUser(data.user.id);
if (deleteError) return json({ error: "delete_failed" }, 500);
return json({ ok: true }, 200);
});
The function deletes whoever owns the token, and only them. There's no user id in the request body to tamper with.
6. Alert when the cron job quietly stops
Not exactly security, but the failure mode is the same: something breaks and nobody notices. Public results come from a pg_cron job that writes a snapshot every 15 minutes. If it stops, the site keeps serving old numbers with a straight face.
The existing anomaly check (vote spikes per party, signup spikes) got one more rule: if the latest snapshot is more than an hour old and the electoral blackout wasn't active in that window (during the blackout, not writing is the whole point), insert a stale_snapshot alert. Alerts go out by email through Resend, called from Postgres with pg_net, with the API key read from Supabase Vault. One alert per episode, so a dead job doesn't turn into an inbox flood.
7. Test it all in a transaction that always rolls back
This is the part that made the rest safe to ship. A small Node script opens one connection to the remote project, runs begin, applies any pending migrations (the ones not yet in supabase_migrations.schema_migrations), runs every file in supabase/tests/, and always ends with rollback:
try {
await client.query("begin");
for (const f of files) {
console.log(`▸ ${f.name}`);
await client.query(f.sql);
}
} catch (e) {
failed = true;
console.error(`✗ ${e.message}`);
} finally {
await client.query("rollback").catch(() => {});
await client.end();
}
process.exit(failed ? 1 : 0);
Tests are plain SQL that raise exception on failure. The default-privileges test is my favourite because it checks the thing I actually care about, the next table, not the current ones:
create table public.t_prelaunch_new (id bigint generated always as identity primary key);
create function public.f_prelaunch_new() returns integer language sql as 'select 1';
do $$
begin
if has_table_privilege('anon', 'public.t_prelaunch_new', 'select')
or has_table_privilege('authenticated', 'public.t_prelaunch_new', 'insert')
or has_sequence_privilege('anon', 'public.t_prelaunch_new_id_seq', 'usage')
or has_function_privilege('anon', 'public.f_prelaunch_new()', 'execute') then
raise exception 'FAIL: a new object is exposed to clients by default';
end if;
if not has_table_privilege('service_role', 'public.t_prelaunch_new', 'select') then
raise exception 'FAIL: service_role lost its default access';
end if;
end $$;
The same file seeds 400 random respondents with a fixed seed (setseed(0.42)) and checks that the refactored snapshot function produces exactly the same aggregates as the original queries, table by table. That refactor computed each eligible population once into temp tables instead of up to three times per run, which is a performance change I'd never have dared to ship without a row-for-row diff.
The checklist, short version
| Area | What I did | Why |
|---|---|---|
| Default grants | Revoke default privileges for anon / authenticated, revoke EXECUTE from PUBLIC | The next table is safe even if I forget |
| RLS | One security definer function, called as (select fn()) in each policy | One rule, evaluated once per query |
| Headers | CSP without nonces, frame-ancestors none, nosniff, strict referrer, no x-powered-by | Keeps ISR, still locks down third parties |
| Redirects | safeNext() with a control-char check and an origin check | ?next= is an open redirect by default |
| Privacy | Self-service JSON export and an Edge Function delete keyed on the caller's token | GDPR, without a support inbox |
| Ops | Stale-snapshot alert via pg_net + Vault | Silent failures are the worst kind |
| Tests | Migrations + SQL tests in one transaction, always rolled back | Validate against the real project without touching it |
Seven items, one commit, zero clever tricks. If you're about to launch something on Next.js and Supabase, number 1 alone is worth the ten minutes. If you want someone to do this kind of pass on your product, that's the kind of work I take on. And if you want to see the result, go and vote on pollitics; the shareable results image still won't show anyone what you picked.