DEV Community

Joan Comadran
Joan Comadran

Posted on Originally published at joancomadran.dev

A pre-launch security checklist for Next.js + Supabase (from a real launch)

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;
Enter fullscreen mode Exit fullscreen mode

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;
$$;
Enter fullscreen mode Exit fullscreen mode

And every policy is a one-liner:

alter policy "results_snapshots_read" on public.results_snapshots
  using (snapshot_at <@ (select public.readable_snapshots()));
Enter fullscreen mode Exit fullscreen mode

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 definer plus set search_path = ''. The function reads snapshot_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 shadowing public.has_voted with their own object.
  • Why two snapshots and not one. Some pages read the latest snapshot_at in 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 }];
  },
};
Enter fullscreen mode Exit fullscreen mode

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 when VERCEL_ENV is preview.
  • frame-ancestors 'none' and X-Frame-Options: DENY say 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 $$;
Enter fullscreen mode Exit fullscreen mode

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.

FAQ

Does Supabase expose new tables to anon and authenticated by default?

Yes. Supabase grants privileges on objects in the public schema to the anon and authenticated roles by default, so a new table, sequence or function is reachable through the API unless RLS or a revoke stops it. On pollitics.es I removed that default with ALTER DEFAULT PRIVILEGES for the postgres role (tables, sequences and functions), plus a global revoke of EXECUTE on functions from PUBLIC, so every new object needs an explicit grant.

Can I use a strict nonce-based CSP with Next.js ISR?

Not without giving up static rendering. Nonces have to be generated per request, which makes every page dynamic and breaks ISR. On pollitics.es I kept ISR and shipped a CSP without nonces: 'unsafe-inline' for scripts (Next needs it), and an explicit allowlist for Supabase and Cloudflare Turnstile, plus frame-ancestors 'none', object-src 'none', base-uri and form-action locked to 'self'.

How do I prevent open redirects with a ?next= parameter in Next.js?

Only accept values that start with a single slash, reject any control character or backslash (browsers strip tabs and newlines and treat a backslash as a slash, so '/\t/evil.com' can end up on evil.com), then resolve the value against a fixed fake origin with new URL() and check the origin didn't change. Return pathname, search and hash, or a safe fallback.

How do I test Supabase migrations without touching the database?

Run the pending migrations and your SQL test files inside a single transaction that always ends in ROLLBACK. Each test raises an exception on failure, so the script exits non-zero and nothing persists either way. On pollitics.es a small Node script with the pg client does this against the remote project.


Originally published on joancomadran.dev. I'm Joan, an AI engineer in Barcelona — work with me.

Top comments (0)