An admin console that costs nothing to host: Vite, Supabase RLS, antd
Moving a feedback admin from Next.js to a static Vite SPA: the anon key plus RLS as the only gate, email-link and PKCE traps, and a CSP that fits antd.

On this page
Once seven apps could send feedback into one Supabase table, as described in One feedback inbox for seven iOS apps, I needed somewhere to read it. A Telegram ping is fine for "something arrived". It's not fine for "show me every open Baton bug from the last two weeks and mark three of them done".
So I built an admin console, and then rebuilt it the same day. The first version was a Next.js app with a proxy, server actions and per-request CSP nonces. The version running now, on a subdomain of my personal domain, is a Vite + React single-page app served as plain static files on Vercel: no server, no serverless functions, nothing billed per request. The rewrite changed 70 files, adding 3,122 lines and removing 6,116.
What made that possible is one decision: let Postgres be the only thing that decides who can see what. This post covers that decision, the row level security that implements it, two email-link traps that cost me a burned sign-in each, and the CSP compromise that Ant Design forces on a static host.
Why the server could go
The Next.js version worked. Its proxy.ts ran on every request and did three jobs: it minted a CSP nonce, refreshed the Supabase session cookie, and redirected signed-out users to /login. Server actions changed a feedback item's status or deleted it, behind an admin check.
When I listed what each piece protected, the list was empty. The data access was already guarded by Postgres. The server actions checked "is this user an admin" and then made the same Supabase call the browser could have made, with the same anon key and the same user session. RLS then checked the same thing again. The redirect to /login is a UX nicety, not a security boundary. The only thing a static host genuinely can't do is the per-request nonce, and that turned out to matter less than I expected (see the CSP section).
It's the same lesson as dropping the BFF in the Saramin admin: a server layer that re-checks what the backend already enforces is ceremony. Here it was cheaper still, because the "backend" is Postgres itself.

The SPA is Vite 8, React 19, react-router, TanStack Query for reads, and plain supabase-js calls for writes. vercel.json sets the framework to vite, the output to dist, and one rewrite that sends every non-asset path to index.html.
The anon key is public, so RLS has to be complete
A browser-only Supabase client means the anon key ships inside the JavaScript bundle. That's fine, and it's what the key is for, but only if the database rules are complete. Anyone can take that key and call the REST API directly, skipping my UI entirely. So the question for each table is not "what does the UI do" but "what can any signed-in or anonymous user do with this key".
The migration that opened the inbox to the admin answers it in four parts.
1. An allow-list nobody can read. admin_users holds the admin emails. RLS is on, there are no policies, and every privilege is revoked from anon and authenticated. It's edited with SQL only.
2. One function that decides.
create or replace function public.is_feedback_admin()
returns boolean
language sql stable
security definer
set search_path = ''
as $$
select coalesce((
select true
from auth.users u
join public.admin_users a on a.email = lower(u.email)
where u.id = auth.uid()
and u.email_confirmed_at is not null
and a.email = lower(auth.jwt() ->> 'email')
limit 1
), false);
$$;
revoke all on function public.is_feedback_admin() from public, anon;
grant execute on function public.is_feedback_admin() to authenticated;security definer lets it read admin_users and auth.users, which the caller can't. search_path = '' stops anyone from shadowing a table name with their own schema. It checks both the JWT's email and the real auth.users row, and requires a confirmed email, so an unconfirmed sign-up with my address gets nothing.
3. Column-limited updates. RLS decides which rows. Column privileges decide which fields. The admin may read and delete feedback but update only the two triage columns:
revoke all on table public.app_feedback from anon, authenticated;
grant select, delete on table public.app_feedback to authenticated;
grant update (status, admin_note) on table public.app_feedback to authenticated;
create policy feedback_admin_update on public.app_feedback
for update to authenticated
using ((select public.is_feedback_admin()))
with check ((select public.is_feedback_admin()));So even a compromised admin session can't rewrite what a user said, and no client role can insert. New rows still come only from the edge function, through its validation.
4. Storage and Realtime follow the same rule. Policies on storage.objects let the admin sign URLs for and delete files in the screenshot bucket. app_feedback joined the Realtime publication, and Realtime applies the same select policy, so only the admin receives change events.

I didn't trust this by reading it. A live test script calls the real project as three identities: anon, a temporary non-admin user, and the admin. It passes 28 of 28 checks. Anon gets 401 on everything. The non-admin sees and changes zero rows and can't sign screenshot URLs. The admin can read, update status and note, and sign and delete screenshots, but can't change message, insert a row, or read admin_users. The same run confirms the edge function still validates.
The UI calls rpc('is_feedback_admin') for exactly one reason: to show "this account has no access" instead of an empty inbox. Hiding things in the UI is a courtesy. The rules above are the gate.
Email links: two traps
The owner account signs in by email, with shouldCreateUser: false so the form can't create accounts. Two things went wrong with the link before it worked.
Trap 1: the Site URL fallback. This Supabase project's Site URL belongs to Linkeeper, and it has to, because Linkeeper's Apple and Google sign-in use it. If the admin asks for a link with redirectTo set to its own callback, and that URL isn't in the project's Redirect URLs allow-list, Supabase doesn't fail. It quietly sends the user to the Site URL instead. The link opens Linkeeper's site, the one-time token is spent, and the admin is still signed out. The fix was to add the admin's origin (and the local dev port) to the allow-list, without touching the Site URL.
Trap 2: PKCE and ?code=. The browser client uses the PKCE flow, so once the redirect was allowed, the link landed on /auth/callback?code=…. My first callback page only understood the older #access_token fragment and showed "invalid link". The code has to be exchanged, and the code verifier for that exchange sits in the localStorage of the browser that requested the email. Open the link in another browser, or in the Mail app's in-app browser, and the exchange can't work. The error page now says so plainly: open the link in the same browser that asked for it.
The callback has two more details worth copying:
// One attempt per page load: the code is single-use and StrictMode runs effects twice.
let attempt: Promise<'signed-in' | 'redirecting'> | null = null;
useEffect(() => {
if (!attempt) {
const href = window.location.href;
window.history.replaceState(null, '', window.location.pathname); // drop tokens from history
attempt = signInFromLink(href);
}
// …
}, []);React's StrictMode runs effects twice in development, and a single-use code exchanged twice fails the second time. A module-level promise makes the exchange happen once. replaceState removes the code from the address bar and history before anything else runs.
signInFromLink accepts ?code=, #access_token, ?token_hash=, and a raw Supabase verify link pasted into the login form. It only ever follows a pasted link if it points at my own Supabase origin. I'd planned to add a six-digit code to the email template too, but templates are locked on the free plan without custom SMTP, so the link stays. Later the same day I added a password sign-in for the one admin account, keeping the email link as the fallback.
On the React side, the auth context mirrors supabase-js's session and every redirect is declarative. Route guards react to the session becoming null, and sign-out removes cached queries. Nothing calls navigate() on auth changes. One redirect owner per transition, the same rule that fixed a logout race in the Saramin admin.
A CSP that fits antd on a static host
The Next.js version had a strict policy: script-src 'self' 'nonce-…' 'strict-dynamic', with a fresh nonce per request from the proxy. A static host serves the same bytes to everyone, so there's no per-request nonce to mint. Two things made that acceptable.
First, scripts don't need a nonce. Vite's production build emits no inline scripts, just hashed files under /assets/. So script-src 'self' is as strict as the old policy in practice, and simpler.
Second, styles can't be strict either way. Ant Design 5 uses CSS-in-JS and injects <style> tags at runtime. Even the Next.js version needed style-src 'unsafe-inline', because React style={} attributes can't carry a nonce. So nothing was lost:
{
"key": "Content-Security-Policy",
"value": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: <project>.supabase.co; connect-src 'self' <project>.supabase.co wss://<project>.supabase.co; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
}connect-src lists the Supabase origin over both https and wss, because Realtime uses a websocket. img-src includes it for the signed screenshot URLs. Next to the CSP, vercel.json sends X-Robots-Tag: noindex, nofollow, noarchive, X-Frame-Options: DENY, HSTS, Referrer-Policy: no-referrer, a locked-down Permissions-Policy, no-cache on the HTML, and immutable on /assets/. vite preview sends the same headers, so I can test the policy locally before deploying.
The console itself
Ant Design was the right call for a tool with one user and a lot of tables. The console has a collapsible sider, an overview with stat cards and a daily chart, and an inbox whose filters live in the URL: app, kind, status, search, date range, install ID, sort, page and size. ?id= opens a detail drawer with the message, the screenshot through a one-hour signed URL, the status, a private note, reply by email, copy as a GitHub issue, and delete. Bulk status changes and deletes work on selected rows, and deleting removes the screenshots first. Under 768 px the table becomes cards, so there's no horizontal scrolling on a phone.
A Realtime subscription on app_feedback invalidates every feedback query when something changes, and a "Live" indicator in the header shows whether the channel is connected.
Checklist
- List what your server layer protects. If Postgres already enforces every check, ship static files.
- Treat the anon key as public and make RLS complete for every role, not just the paths your UI uses.
- Keep the allow-list in a table no client can read, behind a
security definerfunction withsearch_path = ''that also checks a confirmed email. - Limit updates with column grants, not just row policies.
- Put storage policies and the Realtime publication under the same rule.
- Test the rules live as anon, as a non-admin and as the admin.
- Add every callback origin to Redirect URLs. A missing one falls back to the Site URL and burns the token.
- Handle
?code=from PKCE, open the link in the same browser, exchange once (StrictMode), and clear the URL. - On a static host:
script-src 'self'with a build that has no inline scripts, and acceptstyle-src 'unsafe-inline'for antd. - Add
noindex, frame denial, HSTS and immutable asset caching invercel.json, and preview with the same headers.
Related posts


