Skip to main content
ASoc
Tutorial

The Supabase Service Role Key Makes Your WHERE Clause the Policy

Read in one file, guarded by server-only, used at six audited call sites — and why dropping one .eq() returns every user's rows instead of none.

The ASoc Team9 min read

The Supabase service role key authorizes requests as the service_role Postgres role, which carries the BYPASSRLS attribute — it skips every Row Level Security policy on every table. In this storefront's application code it is read in exactly one file, guarded by server-only, and reaches seven call sites across six files. Everywhere else, six RLS policies do the work.

The rule that matters is not "keep it secret". It's that the moment you use it, your where clause becomes the security boundary — and nothing else is checking.

One file reads the key

// src/lib/supabase/admin.ts
import "server-only";
import { createClient as createSbClient } from "@supabase/supabase-js";

/**
 * Service-role Supabase client — SERVER ONLY. Bypasses RLS. Use only in vetted
 * server code (webhook writes, signed-URL issuance). Never import from a Client
 * Component; the `server-only` guard makes such an import fail the build.
 */
export function createAdminClient() {
  return createSbClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!,
    { auth: { persistSession: false, autoRefreshToken: false } },
  );
}

Three things in eleven lines are doing security work.

import "server-only" is the enforcement. It's a package whose only job is to throw at build time if the module ends up in a client bundle. Without it, "don't import this from a Client Component" is a comment; with it, the violation is a failed next build rather than a leaked key in a JavaScript chunk someone can read with view-source. Nine files under src/ carry that import — every module that touches a secret, plus one test that asserts the boundary.

No NEXT_PUBLIC_ prefix. NEXT_PUBLIC_SUPABASE_URL has one because the project URL is public by design and the browser client needs it. SUPABASE_SERVICE_ROLE_KEY deliberately does not, so Next.js will not inline it into client bundles at all. The prefix rule and the build-time inlining behind it are covered in server-only environment variables.

persistSession: false, autoRefreshToken: false. The admin client has no user to keep signed in. Leaving session persistence on invites the client to write a session to whatever storage it can find and to run a refresh timer in a serverless function that is about to be frozen. Neither is wanted; both are off.

Three clients, and why the third is separate

This codebase creates Supabase clients in three files, and the split is the actual design:

FileKeyRuns asRLS
src/lib/supabase/client.tsanon / publishablethe signed-in user, in the browserenforced
src/lib/supabase/server.tsanon / publishablethe signed-in user, on the serverenforced
src/lib/supabase/admin.tsservice rolenobody — full accessbypassed

The first two are the default. The third is the exception you justify per call site. The broader client split, and why every authorization decision here uses getClaims() rather than getSession(), is in Supabase auth in Next.js.

Where the exception is actually allowed

createAdminClient() is called from six files (seven call sites — account.ts needs it twice). Each one has the same shape: something has to happen that the signed-in user is not permitted to do on their own behalf.

Call siteWhy RLS can't serve it
src/app/api/webhooks/lemonsqueezy/route.tsA payment webhook has no user session at all — the request comes from LemonSqueezy
src/app/api/download/route.tsIssues a signed URL for a private storage object the user may never list directly
src/lib/actions/redemption.tsWrites entitlement slots; the user must not be able to grant themselves one
src/lib/actions/account.tsAccount mutations that cross the read-own-only policy set
src/lib/actions/refund.tsRecords a refund request against an order row the user can only read
src/lib/email/refundRequest.tsLooks up the recipient for a transactional email outside any user request

The webhook is the clearest case. There is no auth.uid() during a webhook, so using ((select auth.uid()) = user_id) evaluates against null and matches nothing — RLS would deny every row. That is RLS working correctly, and it is why the service role exists.

The line that is easy to get wrong

Here is the download route, with the comment that states the whole discipline:

// src/app/api/download/route.ts
async listActiveSlots(userId) {
  // The admin client bypasses RLS — this explicit `user_id`/`status`
  // filter IS the security boundary (R-5), not defense-in-depth on top
  // of RLS.
  const { data, error } = await admin
    .from("entitlement_slots")
    .select("kind, product_slug, framework, status")
    .eq("user_id", userId)
    .eq("status", "active");
  // …
}

Drop .eq("user_id", userId) from an anon-key query and you get zero rows, because the policy filters to the caller's own. Drop it here and you get every user's entitlements, and the endpoint hands the caller downloads they never bought. Same typo, two completely different outcomes.

This is why userId in that function comes from supabase.auth.getUser() on the user's own session a few lines above — never from a query parameter. An admin-client query filtered by a user-supplied id is an IDOR with extra steps, and this repo has dedicated tests (src/lib/__tests__/redemption-idor.test.ts) pinning that down.

What the policies look like when you're not bypassing them

Six tables, six policies, all select:

-- supabase/migrations/0001_commerce_init.sql
alter table public.profiles          enable row level security;
alter table public.orders            enable row level security;
alter table public.entitlement_slots enable row level security;
alter table public.download_events   enable row level security;

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);

Two more tables — download_deliveries and refund_requests — arrive in later migrations with the same own-row select shape, for six in total.

Note what is absent: no insert, update or delete policy anywhere. With RLS enabled and no policy for an action, that action is denied — so a user's own client cannot write to these tables at all. Every write goes through the service role or a SECURITY DEFINER RPC. That's a deliberate shape: reads are the user's, writes are the system's.

The gotcha: a service key that still gets RLS errors

The single most confusing failure with this key is that it appears not to bypass RLS. Supabase's docs are explicit about the cause: the client adheres to the RLS policy of the signed-in user even when it was initialized with a service key — because passing a user's Authorization header downgrades the request to that user's role. The key in createClient() sets the default; a per-request user token overrides it.

So if an admin query is returning empty or permission-denied, check whether anything attached a user JWT to that client before you start rewriting policies.

Legacy service_role vs. the new secret keys

Supabase has changed how API keys work. The legacy anon and service_role keys are JWTs derived from the project's JWT secret, which makes them hard to rotate without downtime — rotating the secret invalidates both at once. The replacements are publishable (sb_publishable_…) and secret (sb_secret_…) keys, which are created, named and revoked independently.

Per Supabase's migration guide, secret keys add protections the service_role key doesn't have: they return HTTP 401 if used from a browser (matched on the User-Agent header), and you can issue a separate key per service so one leak forces one rotation rather than a project-wide one. Both key types work simultaneously, so the swap is incremental — replace the anon key with a publishable key in client code, the service role key with a secret key in backend code.

What does not change is everything above. A secret key still authorizes as service_role, still carries BYPASSRLS, and still makes your where clause the security boundary. It is a better-operated key, not a safer one.

Mistakes and troubleshooting

SymptomCauseFix
Service-role client returns RLS errors or no rowsA user's Authorization header was attached, downgrading the request to that user's roleDon't forward user tokens to the admin client
Key is undefined at runtimeRead in code that ended up client-side, or the var isn't set in the deploy environmentAdd import "server-only" so it fails at build, and set the var in the host
A query returns other users' rowsAn admin-client query without an explicit user_id filterFilter explicitly; treat the where clause as the policy
Users can't write their own rowsRLS on with select-only policies denies insert/update/deleteIntended here — route writes through vetted server code or a SECURITY DEFINER RPC
The key leakedIt bypasses everything; rotating the JWT secret rotates anon tooMigrate to a secret key so it can be revoked on its own
Webhook writes are deniedNo auth.uid() during a webhook, so own-row policies match nothingThis is the legitimate service-role case

Frequently asked questions

Is it safe to use the service role key in a Next.js Server Component or Server Action? It runs on the server, so it won't leak — but only if you can prove the module never reaches a client bundle. Use import "server-only" so an accidental Client Component import fails the build instead of shipping the key.

Does the service role key bypass RLS completely? It authorizes as service_role, which has Postgres's BYPASSRLS attribute, so policies are skipped. The exception is the one above: if a user's JWT rides along on the request, the request runs as that user instead.

Can I use the service role key in the browser if I'm careful? No. Anyone can read it out of a bundle or a network request and then has full read/write on every table. Newer secret keys refuse browser use outright by rejecting requests with a browser User-Agent.

Should I migrate from service_role to a secret key? Supabase recommends it, and the operational case is rotation: a secret key can be revoked alone, per service, without touching the rest of your app. Both work at once, so migrate backend call sites incrementally.

Do I still need RLS if all my queries go through the server? Yes. RLS is the backstop for the day a query forgets its filter or a table is exposed through a path you didn't plan. In this codebase RLS is what protects the six tables on every path that isn't one of the six audited admin call sites.

Templates in this post

ASoc Steep is a tea and teaware store built around single-origin leaf, with bestseller, new-arrival and featured product rows, shop-by-leaf collections, customer reviews and gift wrapping. ASoc Stride is a sneaker store spanning five categories with three shop layouts, product detail, cart, wishlist, checkout and account pages. ASoc Tote is a bags-and-accessories store across six categories with sale-badged product cards, a bestseller edit, and cart, wishlist and checkout flows.

Browse the full sets: Next.js shop templates, Tailwind shop templates.

Keep reading

Tutorial8 min read

Supabase SQL Editor: This Schema Has Never Been Edited Through It

Studio's SQL Editor is built for one-off queries. Every schema change here shipped instead as one of 8 reviewed migration files — what each tool is actually for.

Read more