Skip to main content
ASoc
Tutorial

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.

The ASoc Team8 min read

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 featureUsed here?Why
Postgres databaseYesThe entire commerce schema — profiles, orders, entitlement slots, download events, refund requests
Auth (email/password + OAuth)YesEmail/password and Google sign-in, session refreshed on every request
Row Level SecurityYes6 policies are the entire authorization boundary — see below
Storage (private buckets, signed URLs)YesOne private releases bucket; every zip download is a 60-second signed URL, never a public path
Server-side functions (RPCs)Yes3 SECURITY DEFINER functions do every write that has to be atomic
Realtime subscriptionsNoNo .channel( call anywhere in the codebase — nothing here needs a live feed
Edge FunctionsNoServer Actions and Route Handlers already run on Vercel; there's no reason to run a second server-side runtime
Auto-generated GraphQL/RESTIndirectlyThe 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

MistakeWhat happensFix
Calling getSession() in server code and trusting the resultThe cookie's claims are used without verifying the JWT signature firstUse getClaims(), which validates before returning
Enabling a table with RLS on and adding zero policiesEvery row becomes invisible, including to its own owner — Postgres defaults to denyAdd an explicit for select using (auth.uid() = user_id) policy per table
A count-then-insert rate limit in application codeA TOCTOU race: concurrent requests can all read the same count and all passMove count + insert into one SECURITY DEFINER function with an advisory lock
Leaving EXECUTE on a SECURITY DEFINER function grantable to anon/authenticatedAny authenticated client can call a privileged function directly, bypassing the app's own checksrevoke execute ... from anon, authenticated, public — call it only from the service-role server client
Generating a long-lived or public storage URL for paid contentA leaked link keeps working indefinitelyShort-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.

Keep reading

Tutorial8 min read

What Is a Web Page Template? Two Products Share the Word

A hosted design you edit in a builder, and a source repo you own, are both called templates. What is actually inside one, from a catalog of 111.

Read more
Tutorial9 min read

Auth in React: The Session Belongs in a Cookie, Not in Context

Six Server Actions, a 68 KiB auth SDK kept out of every initial bundle, and one line that decides the whole security posture: getClaims(), never getSession().

Read more
Tutorial9 min read

Best SaaS Landing Pages: What 17 Real Templates Actually Ship

Not a gallery — a section-by-section census of 17 SaaS landing templates. Pricing, testimonials and integrations each appear in 10 of 17; the median product is 11 pages.

Read more