What Is Supabase Used For? Four Things, in One Real App
Four things, precisely: auth, row-level authorization, private storage, atomic writes -- including the rate-limit race this app actually hit and fixed.
Supabase is a hosted Postgres database wrapped with an authentication service, file storage, auto-generated APIs, realtime subscriptions, and edge functions — sold as "the pieces a backend needs, without running your own servers." Most apps that adopt it don't use all of that. This one uses exactly four pieces — authentication, row-level authorization, private file storage, and atomic server-side writes — and nothing here ever reaches the database except through those four paths.
What Supabase ships, and what this app actually touches
| Supabase feature | Used here? | Why |
|---|---|---|
| Postgres database | Yes | The entire commerce schema — profiles, orders, entitlement slots, download events, refund requests |
| Auth (email/password + OAuth) | Yes | Email/password and Google sign-in, session refreshed on every request |
| Row Level Security | Yes | 6 policies are the entire authorization boundary — see below |
| Storage (private buckets, signed URLs) | Yes | One private releases bucket; every zip download is a 60-second signed URL, never a public path |
| Server-side functions (RPCs) | Yes | 3 SECURITY DEFINER functions do every write that has to be atomic |
| Realtime subscriptions | No | No .channel( call anywhere in the codebase — nothing here needs a live feed |
| Edge Functions | No | Server Actions and Route Handlers already run on Vercel; there's no reason to run a second server-side runtime |
| Auto-generated GraphQL/REST | Indirectly | The client library uses the REST layer under the hood; nothing calls it directly by URL |
That's the actual shape of "using Supabase" in a production app: a subset of what's marketed, chosen deliberately, not a checklist to complete.
1. Auth: two sign-in methods, one session refresh
Email/password and Google OAuth both go through src/lib/actions/auth.ts as Server Actions. The part that's easy to get wrong is keeping the session valid across requests without trusting the client's word for who's signed in. This runs on every request that isn't a static asset:
// src/proxy.ts (Next 16 proxy, formerly middleware)
export async function proxy(request: NextRequest) {
let response = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => request.cookies.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value));
response = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options),
);
},
},
},
);
await supabase.auth.getClaims(); // validates the JWT — never getSession()
return response;
}
getClaims() is the deliberate choice over getSession(): getSession() reads whatever the cookie says without re-verifying it, while getClaims() validates the JWT's signature before anything downstream trusts it. Skipping that distinction is the single most common Supabase auth mistake — a session cookie is user-controlled input until it's been verified, not a fact. The full wiring around it — the three separate clients, the six Server Actions and the one PKCE callback all three flows share — is laid out in Supabase Auth in Next.js 16.
2. Row Level Security: the entire authorization layer, in 6 policies
Supabase doesn't add its own permission system on top of Postgres — it turns on Postgres's own Row Level Security and expects you to write the policies. This app's are all the same shape: a row is visible only to the user it belongs to.
-- supabase/migrations/0001_commerce_init.sql
create policy "own profile" on public.profiles for select using ((select auth.uid()) = id);
create policy "own orders" on public.orders for select using ((select auth.uid()) = user_id);
create policy "own slots" on public.entitlement_slots for select using ((select auth.uid()) = user_id);
create policy "own downloads" on public.download_events for select using ((select auth.uid()) = user_id);
-- supabase/migrations/0008_refund_requests.sql
create policy "own deliveries" on public.download_deliveries for select using ((select auth.uid()) = user_id);
create policy "own refund requests" on public.refund_requests for select using ((select auth.uid()) = user_id);
Every one of the six is for select, read-only. There is no policy that lets a client write directly to any of these tables — every write goes through a function (below), never a client-issued insert/update. That's a narrower authorization surface than most Supabase apps ship: read is row-scoped by policy, write is gated entirely by server-controlled functions the client can only call, never bypass.
3. Storage: one private bucket, signed URLs that expire in 60 seconds
Buyer downloads live in a private releases bucket — nothing in it is public. A download request resolves to a 60-second signed URL, generated only after authentication, email verification, and an entitlement check all pass:
// src/lib/download.ts
export const SIGNED_URL_TTL_SECONDS = 60;
// path is only ever built server-side from the catalog's own version string —
// never from a client-supplied path — then handed to Storage for a signed URL.
const path = `${effectiveProductSlug}/${framework}/${effectiveProductSlug}-${framework}-v${version}.zip`;
A signed URL is meant to be used immediately, not stored or shared — 60 seconds is long enough for a browser to start the download and short enough that a leaked link is worthless a minute later.
4. Atomic writes: why three functions exist instead of client-side inserts
The riskiest thing an app can do against Postgres is a read-then-write from application code: check a count, decide, then insert — with a window in between where a second request can slip through. This app hit that bug for real. The original download-rate-limit check read the count, then inserted the audit row as a separate step; under concurrency, several parallel requests could all read the same count, all pass, and all insert — busting the hourly cap. The fix moved both steps into one SECURITY DEFINER function, serialized per user with a transaction-scoped advisory lock:
-- supabase/migrations/0007_atomic_download_rate_limit.sql
perform pg_advisory_xact_lock(hashtextextended(p_user_id::text, 0));
select count(*)::int into recent from public.download_events
where user_id = p_user_id and created_at > now() - interval '1 hour';
if recent >= p_limit then
return -1; -- over limit — record nothing
end if;
insert into public.download_events (user_id, product_slug, framework, version, ip, user_agent)
values (p_user_id, p_product_slug, p_framework, p_version, p_ip, p_user_agent);
return recent + 1;
create_order_with_slots and refund_order follow the same shape: whatever has to be correct under concurrency runs inside Postgres as one call, not as multiple round-trips from the app. All three revoke EXECUTE from anon and authenticated — only the server's own service-role client can call them, so a client can't invoke a privileged function directly even with valid credentials.
Common mistakes
| Mistake | What happens | Fix |
|---|---|---|
Calling getSession() in server code and trusting the result | The cookie's claims are used without verifying the JWT signature first | Use getClaims(), which validates before returning |
| Enabling a table with RLS on and adding zero policies | Every row becomes invisible, including to its own owner — Postgres defaults to deny | Add an explicit for select using (auth.uid() = user_id) policy per table |
| A count-then-insert rate limit in application code | A TOCTOU race: concurrent requests can all read the same count and all pass | Move count + insert into one SECURITY DEFINER function with an advisory lock |
Leaving EXECUTE on a SECURITY DEFINER function grantable to anon/authenticated | Any authenticated client can call a privileged function directly, bypassing the app's own checks | revoke execute ... from anon, authenticated, public — call it only from the service-role server client |
| Generating a long-lived or public storage URL for paid content | A leaked link keeps working indefinitely | Short-TTL signed URLs (60s here), generated only after an authorization check |
Frequently asked questions
Is Supabase just "Postgres hosting"? No — the database is one piece. It also ships an authentication service (email/password, OAuth providers, JWT sessions), private/public file storage with signed URLs, auto-generated REST and GraphQL layers over your schema, realtime subscriptions, and edge functions. Whether you use all of that is a per-app decision.
Do you have to use every Supabase feature once you adopt it? No, and this app is the evidence: Realtime and Edge Functions are both unused here — there's no live-updating UI that needs a subscription, and Server Actions on Vercel already cover the "run code near the request" job Edge Functions would do. Fewer features touched is less surface to secure.
Is Row Level Security mandatory?
Postgres won't force you to enable it, but an app that skips it is trusting every query to be written correctly forever — one missing WHERE user_id = ... clause anywhere in the codebase becomes a data leak. This app treats RLS as the actual authorization boundary: even if application code got a filter wrong, the database itself still won't return another user's row.
How is this different from wiring up plain Postgres myself? Plain Postgres gives you the database and nothing else — you'd build your own auth service, your own file storage with access control, and your own connection/session handling. Supabase's value here is exactly the pieces this app leans on: hosted auth, RLS-enforceable Postgres, and private storage, all reachable from the same client library.
Templates in this post
ASoc Flow (a workflow-automation landing page), ASoc Folio (a developer portfolio template) and ASoc Forge (an AI resume-builder landing page) sit in the same catalog this backend serves — each entitled, downloaded, and rate-limited through the exact path described above.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For how Supabase compares to specific alternatives on these same axes, see Supabase vs. Neon, Supabase vs. Convex and Supabase vs. Appwrite; for the gated-download route this storage pattern feeds, see Next.js Gated File Downloads.
