SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
May 9, 2026·Security·SecureStartKit Team

Backend-Only Data Access in Next.js + Supabase [2026]

The architectural pattern that prevents Supabase data leaks. Server Actions, admin client, no NEXT_PUBLIC key for queries, ever.

Summarize with AI

On this page

  • Table of contents
  • What is backend-only data access?
  • Why does this pattern prevent Supabase breaches?
  • How do you implement it in Next.js + Supabase?
  • The browser client (`lib/supabase/client.ts`)
  • The server client (`lib/supabase/server.ts`)
  • The Server Action (`actions/billing.ts`)
  • Where do RLS policies fit if every query runs server-side?
  • How do you enforce the boundary in code?
  • 1. Code organization
  • 2. The `server-only` package
  • 3. Code review checklist
  • Five mistakes that break the pattern
  • What this means for your Next.js + Supabase architecture

On this page

  • Table of contents
  • What is backend-only data access?
  • Why does this pattern prevent Supabase breaches?
  • How do you implement it in Next.js + Supabase?
  • The browser client (`lib/supabase/client.ts`)
  • The server client (`lib/supabase/server.ts`)
  • The Server Action (`actions/billing.ts`)
  • Where do RLS policies fit if every query runs server-side?
  • How do you enforce the boundary in code?
  • 1. Code organization
  • 2. The `server-only` package
  • 3. Code review checklist
  • Five mistakes that break the pattern
  • What this means for your Next.js + Supabase architecture

Backend-only data access in Next.js + Supabase means every database query runs on the server, never in a 'use client' file. The browser handles auth flows with the publishable key. The server handles every read and write with the service role key. That single boundary, enforced through code organization and the server-only package, prevents the breach class that exposed Lovable's 170+ apps in 2026 [3].

The architectural choice sounds simple. The hard part is enforcement: keeping the boundary intact across hundreds of files, dozens of mutations, and a team that doesn't always remember which client to import [1].

TL;DR:

  • The pattern: every database query runs server-side via Server Actions or an admin client. Client components never call createClient() for data.
  • What it replaces: RLS-only protection, browser-bundle service_role leaks, client-side supabase.from('table') queries from React components.
  • Why: even with RLS, client-side queries widen the attack surface to "anyone who can read the browser bundle." A scan of 20,000+ indie launch URLs found 11% exposed Supabase credentials in their frontend code [3].
  • How to enforce: code organization (lib/supabase/admin.ts vs client.ts), the server-only package as a build-time guard, code review that flags createClient() outside 'use client' files.
  • Net result: the browser cannot call your database directly. There is no attack surface to misconfigure.

Table of contents

  • What is backend-only data access?
  • Why does this pattern prevent Supabase breaches?
  • How do you implement it in Next.js + Supabase?
  • Where do RLS policies fit if every query runs server-side?
  • How do you enforce the boundary in code?
  • Five mistakes that break the pattern
  • What this means for your Next.js + Supabase architecture

What is backend-only data access?

Backend-only data access is an architectural pattern where every database query happens on the server, and the browser never holds credentials capable of querying the database directly. The browser is allowed to authenticate (sign in, sign out, refresh tokens) and to display data the server has already fetched. The browser is not allowed to ask the database for anything.

Concretely, in a Next.js + Supabase application this means:

  • Browser-side code (any file with 'use client' or any file that imports browser APIs) uses createBrowserClient from @supabase/ssr for auth flows only. It never calls supabase.from('table').select() or any data query.
  • Server-side code (Server Actions, Route Handlers, Server Components, middleware) uses two clients: a server client with cookie-based auth for reading the current user, and an admin client with the service_role key for actual data queries.
  • The service_role key lives only in environment variables that are not prefixed with NEXT_PUBLIC_. It never enters the browser bundle, never appears in network requests visible to a logged-out user, and never gets logged client-side.

The pattern inverts the default Supabase tutorial. Most Supabase tutorials show you how to query directly from a React component. That is fine for a hobby project. It is not safe for a SaaS handling user data.

Why does this pattern prevent Supabase breaches?

The 2026 breach reports tell a consistent story. In January 2026, security researchers at Hacktron AI disclosed CVE-2025-48757: 170+ applications built with Lovable had exposed databases. The vulnerability was missing Row Level Security policies on tables that the publicly available anon API key could query [3]. One breach exposed 13,000 users through a publicly queryable password reset token table.

The deeper pattern: 83% of exposed Supabase databases stem from RLS misconfigurations [3]. Why is this pattern so common? Because RLS-only architectures put a single enforcement layer between the public internet and your data. One missing policy, one wrong predicate, one table that ships with RLS DISABLED because someone "just wanted to test it," and the browser bundle that already contains the URL and anon key becomes a working curl command.

Backend-only data access changes the math. The anon key still sits in the browser bundle (it has to, for auth). But there is nothing useful for it to query, because the database tables are reachable only through Server Actions, and Server Actions are gated by code you wrote, not by a 30-line SQL policy you might have forgotten to add.

This is what we mean when we say security-first SaaS architecture treats the boundary as primary. RLS is defense in depth. The architectural boundary is the actual defense. For a deeper look at how RLS failures led to real breaches, see why 170+ vibe-coded apps got hacked.

How do you implement it in Next.js + Supabase?

Three files do most of the work. Here is the actual SecureStartKit implementation, simplified.

The browser client (lib/supabase/client.ts)

import { createBrowserClient } from '@supabase/ssr'
import type { Database } from './database.types'

// SECURITY: Only use for auth operations, NEVER for data queries
export function createClient() {
  return createBrowserClient<Database>(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )
}

Five lines. The comment is the most important part of the file. This client exists to sign users in, sign them out, and refresh tokens. It does not query data. Anyone reviewing a PR that imports this file should see a supabase.auth.* call. If they see supabase.from('table'), the PR gets flagged.

The server client (lib/supabase/server.ts)

import { createClient as createSupabaseClient } from '@supabase/supabase-js'
import { cookies } from 'next/headers'
import { createServerClient } from '@supabase/ssr'
import type { Database } from './database.types'

// SECURITY: This bypasses RLS - use only in Server Actions/API Routes
export function createAdminClient() {
  return createSupabaseClient<Database>(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!,
    {
      auth: {
        autoRefreshToken: false,
        persistSession: false,
      },
    }
  )
}

// Server-side client for auth verification (uses cookies)
export async function createServerClientWithCookies() {
  const cookieStore = await cookies()

  return createServerClient<Database>(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() { return cookieStore.getAll() },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            )
          } catch {
            // Server Components cannot set cookies. Middleware handles refreshes.
          }
        },
      },
    }
  )
}

export async function getUser() {
  const supabase = await createServerClientWithCookies()
  const { data: { user } } = await supabase.auth.getUser()
  return user
}

Two clients live in this file. createAdminClient() uses the service_role key and bypasses RLS. It is the only client that runs INSERT, UPDATE, DELETE, or any privileged read. createServerClientWithCookies() uses the cookie session to identify the current user. It does not query data; it just answers "who is this request from?"

Supabase's own docs now recommend supabase.auth.getClaims() over getUser() for protecting pages, because getClaims() validates the JWT signature against published public keys on every call [1]. Both are safer than reading from the cookie without validating the JWT. If you adopt getClaims(), the rest of the architecture is identical.

The Server Action (actions/billing.ts)

This is what a real mutation looks like in the pattern.

'use server'

import { z } from 'zod'
import { redirect } from 'next/navigation'
import { createAdminClient, getUser } from '@/lib/supabase/server'
import { getStripe } from '@/lib/stripe/client'

const checkoutSchema = z.object({
  priceId: z.string().min(1),
  productName: z.string().optional(),
  locale: z.string().optional(),
})

export async function createCheckoutSession(
  data: z.infer<typeof checkoutSchema>
) {
  // 1. Validate input with Zod (server-side, never trust the client)
  const parsed = checkoutSchema.safeParse(data)
  if (!parsed.success) {
    return { error: 'Invalid input' }
  }

  // 2. Identify the current user from the cookie session
  const user = await getUser()
  if (!user) {
    redirect('/login?next=' + encodeURIComponent('/#pricing'))
  }

  // 3. Use the admin client for the actual data work
  const admin = createAdminClient()
  const { data: customer } = await admin
    .from('customers')
    .select('stripe_customer_id')
    .eq('id', user.id) // user.id from the validated session, never from input
    .single()

  // ... rest of the checkout flow
}

Three trust boundaries are visible in those 30 lines. Input validation with Zod, before any database call. Identity validation with getUser(), before any privileged operation. Server-side data access with createAdminClient(), after both checks have passed. The user.id used as a query parameter comes from the validated cookie session, never from the form payload.

For the full Server Actions + Zod pattern, see the Server Actions and Zod guide. For the full auth flow including OAuth and magic links, see the Supabase authentication guide.

Where do RLS policies fit if every query runs server-side?

RLS is defense in depth. The admin client bypasses RLS entirely, so once you commit to backend-only data access, RLS is no longer your primary defense. It becomes a safety net.

The recommended posture: enable RLS on every table with no policies [2]. This denies all access to the anon and authenticated roles by default. The service_role role bypasses RLS regardless, so the admin client still works. The result: if a 'use client' file ever does sneak in a supabase.from('table') query, it returns nothing instead of leaking data.

-- Enable RLS with deny-all defaults on every table
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.customers ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.purchases ENABLE ROW LEVEL SECURITY;

-- No policies = deny-all to anon and authenticated
-- service_role bypasses RLS = admin client still works

This is more restrictive than the typical Supabase setup, where developers write RLS policies for each table that allow specific access patterns. The tradeoff: you cannot query directly from React components even if you wanted to. The benefit: you eliminate an entire class of authorization bugs. A misconfigured RLS policy can expose every row in a table. No policy at all means no misconfiguration to make.

If you do need granular RLS policies (for example, to support a future read-only client-side query), the free Supabase RLS policy generator scaffolds them for common access patterns. The RLS patterns guide covers the policies that actually hold up under attack.

How do you enforce the boundary in code?

Writing the pattern is the easy part. Keeping it intact as the codebase grows is where most projects fail. Three layers of enforcement work together.

1. Code organization

Two files, named differently, with comments that explain the difference.

lib/supabase/
├── client.ts   // Browser client, auth only, "USE only in 'use client' files"
└── server.ts   // Admin client + server cookie client, "USE in Server Actions/Route Handlers"

Reviewers reading a PR see the import path. import { createClient } from '@/lib/supabase/client' in a Server Action is a smell. import { createAdminClient } from '@/lib/supabase/server' in a Client Component is a build error.

2. The server-only package

The server-only package "poisons" a module so that if a client component imports from it, the application crashes at build time with a clear error [4]. Add it to the top of any module that should never reach the browser:

// lib/supabase/server.ts
import 'server-only'
import { createClient as createSupabaseClient } from '@supabase/supabase-js'
// ... rest of the file

Now the build itself enforces the boundary. If a developer accidentally imports createAdminClient from a 'use client' file, Next.js will not compile the bundle. This is the single most effective enforcement mechanism in the pattern. It costs nothing and catches mistakes at the earliest possible point [6].

3. Code review checklist

Three patterns to flag in every PR:

  • createClient() from @/lib/supabase/client in a server file. Auth-only client used for data queries. Wrong client.
  • createAdminClient() from @/lib/supabase/server in a 'use client' file. Service role about to ship in the browser bundle. Add import 'server-only' to the import target if it isn't there.
  • NEXT_PUBLIC_ prefix on a service role or secret key. Audit your .env files in every PR. The grep for NEXT_PUBLIC_SUPABASE_SERVICE_ROLE should always return zero matches.

The pre-launch security checklist automates the audit pass before deploy. The security hardening checklist covers the surrounding hardening surface.

Five mistakes that break the pattern

These are the patterns that cause the boundary to leak in real codebases.

1. Client-side queries "just for development"

A developer adds supabase.from('users').select() from a React component to debug something. The query works because RLS is permissive. The PR ships. The query is now permanent, the security model is now broken, and the next person to read the file assumes this is the project's pattern.

The fix: never. There is no "development mode" version of this. If you need to inspect data, use the Supabase dashboard or write a quick Server Action.

2. service_role key with a NEXT_PUBLIC_ prefix

Name a service role key NEXT_PUBLIC_SUPABASE_SERVICE_ROLE (because the public anon key is also called NEXT_PUBLIC_) and you ship full database access to every visitor. It happens. AI-generated code does it. Audit your env vars before every deploy:

grep -r "service_role\|SUPABASE_SERVICE" --include="*.ts" --include="*.tsx" \
  --exclude-dir=node_modules --exclude-dir=.next

Any match in a file that doesn't have 'use server' or import 'server-only' is a problem.

3. Passing user IDs from form input as cache keys or query parameters

A Server Action takes a userId from the form payload and uses it as WHERE user_id = $1. The admin client bypasses RLS, so the query returns whatever row the user requested, even if that row belongs to someone else. Never use raw form input as a privileged identifier. Always pull the user ID from the validated session via getUser() or getClaims().

4. Forgetting 'use server' at the top of an action file

Server Actions are a runtime mechanism. They need the 'use server' directive at the top of the file (or function). Forget it, and Next.js will try to bundle the action for the client. Depending on your import graph, this either crashes the build (good, you noticed) or silently includes server-side code in the browser bundle (bad). The server-only package catches the silent case.

5. Importing the admin client from a Route Handler that gets called from the browser

A Route Handler at /api/profile/[id] is reachable from any browser. If it uses the admin client without checking the caller's identity first, anyone with curl can query any profile. Route Handlers are server code, but they are not gated by the framework the way Server Actions are. Always validate the session before any privileged operation.

What this means for your Next.js + Supabase architecture

Backend-only data access is the architectural commitment that makes everything else easier. RLS becomes a safety net instead of a primary defense. Code review becomes a question of "did the author import the right client?" instead of "did they write a 30-line SQL policy correctly?" New tables ship with deny-all defaults and stay safe regardless of how many policies you have or have not written.

The cost is real: you give up the convenience of querying directly from React components. The win is bigger: you eliminate the breach class that has hit Lovable, Moltbook, and dozens of other Supabase apps in 2026.

This is the architecture SecureStartKit ships with by default. The boundary is enforced through lib/supabase/admin.ts versus client.ts, the server-only package on the admin module, and Server Actions in /actions/ that follow the validate-then-authorize-then-query pattern shown above. If you want it pre-wired (along with Zod schemas, RLS deny-all migrations, and signed Stripe webhooks), that is what SecureStartKit is. The site you are reading is the demo.

Built for developers who care about security

SecureStartKit ships with these patterns out of the box.

Backend-only data access, Zod validation on every input, RLS enabled, Stripe webhooks verified. One purchase, lifetime updates.

View PricingSee the template in action

References

  1. Setting up Server-Side Auth for Next.js— supabase.com
  2. Row Level Security— supabase.com
  3. SupaPwn: Hacking Our Way into Lovable's Office and Helping Secure Supabase— hacktron.ai
  4. server-only - npm— npmjs.com
  5. Getting Started: Updating Data— nextjs.org
  6. Server and Client Components— nextjs.org

Related Posts

Mar 3, 2026·Security

Vibe Coding Security Checklist: Audit AI Apps [2026]

Vibe coding tools like Cursor and v0 build apps fast, but they often ship vulnerabilities. Here is the technical audit checklist for Next.js and Supabase apps.

May 9, 2026·Technical

Why We Chose Next.js + Supabase + Stripe for Secure SaaS [2026]

The architectural reasoning behind the SecureStartKit stack. Every layer choice is a security choice, here's why these three, and why no alternatives slot in cleanly.

Apr 28, 2026·Tutorial

Secure File Uploads in Next.js + Supabase Storage [2026]

Most Supabase upload tutorials skip RLS on the bucket and trust the client. Here's how to upload securely in Next.js with Server Actions, signed URLs, and validation.