Security · Next.js + Supabase + Stripe ·

Supabase Security Best Practices: 8 Misconfigs and Fixes

Disclosure: we made the free security checker mentioned in this guide, and the Launch Audit linked at the end is our own paid product.

If you want the next step, read Supabase “Permission Denied for Table”: the Data API Grants Change Scheduled for 30 Oct.

Security problems in a Next.js, Supabase and Stripe app are often not exotic. They're a missing line, a wrong prefix or a policy that looks right and isn't. As of November 2026, the eight misconfigurations below are eight of the checks in our free checker. Each one has what's wrong, a minimal failing snippet, a fixed snippet, why it matters and the official documentation behind it. We checked every source on 2 Nov 2026. We don't describe any breach or statistic here, only what the docs say and what the code does.

The snippets are short on purpose, so they omit imports and error handling you'll want in production. In the Next.js examples, createClient is a small helper in lib/supabase/server, the usual @supabase/ssr pattern.

This is a practical starting list, not a complete set of best practices, and it isn't security advice for your particular app. Each “Fixed” snippet is designed to address the pattern shown, not to guarantee that an app is secure. Your setup may differ, so check the cited docs, and consider an independent review if your app handles sensitive data or payments.

1. A table in public with Row Level Security off

What's wrong. A new table is created and nothing else happens. No enable row level security, no policies. Supabase's Row Level Security guide is blunt about the result: a table in an exposed schema without RLS “is readable and writable by any role with a grant on it.”

Failing

create table public.invoices (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references auth.users (id),
  amount_cents integer not null
);
-- no RLS, no policies

Fixed

create table public.invoices (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references auth.users (id),
  amount_cents integer not null
);

alter table public.invoices enable row level security;
revoke all on table public.invoices from anon, authenticated;
grant select on table public.invoices to authenticated;

create policy "Users read their own invoices"
on public.invoices for select
to authenticated
using ( (select auth.uid()) = user_id );

Why it matters. Tables in an exposed schema are reachable through the Data API with the publishable key, and RLS is the layer that decides which rows a request can touch. The guide also says grants and policies are two separate checks: “Adding policies doesn't take those grants back.” The fix above sets both in one migration. Depending on your project's age and settings, new tables may or may not get default grants, so don't assume either way. Our guide to the Supabase Data API grants change covers the grant side in detail.

Source: Supabase docs, Row Level Security (the “Danger” note, “Grants and policies” and “Enable RLS and set the grants”).

2. A write policy that says using (true)

What's wrong. An AI agent or a tired human gets a permission denied or an empty result and “fixes” it with a policy that matches every row. Supabase explains a policy as adding a WHERE clause to every query, so using (true) is a WHERE clause that never filters anything. On an update policy, with check (true) also lets the caller rewrite the row into anything, including someone else's user_id.

Failing

create policy "anyone can update profiles"
on public.profiles
for update
using (true)
with check (true);

Fixed

create policy "Users update their own profile"
on public.profiles
for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );

Why it matters. Per the guide, using decides which existing rows can be updated, and with check decides what the resulting row may look like, “which stops a user from reassigning user_id to someone else.” The guide also says to always name the role with to: without it, the expression also runs for anon requests. The guide also notes that an UPDATE needs a matching SELECT policy to work as expected, so pair this policy with a select policy for the same rows. A true policy is fine for data that is meant to be public to read, but the same guide says to use it only for that.

Source: Supabase docs, Row Level Security (“What a policy does”, “UPDATE policies” and “Specify roles in your policies”).

3. A security definer function with no pinned search_path

What's wrong. A security definer function runs with the privileges of whoever created it, which on Supabase is usually a role that bypasses RLS. If it doesn't pin search_path and lives in the public schema, two things go wrong: anyone can call it over the Data API, and an unqualified name inside it can be redirected to an object the caller controls.

Failing

create function public.is_admin()
returns boolean
language sql
security definer
as $$
  select exists (
    select 1 from profiles
    where user_id = auth.uid() and role = 'admin'
  )
$$;

Fixed

create schema if not exists private;

create function private.is_admin()
returns boolean
language sql
security definer
set search_path = ''
as $$
  select exists (
    select 1 from public.profiles
    where user_id = (select auth.uid()) and role = 'admin'
  )
$$;

revoke execute on function private.is_admin() from public;
grant usage on schema private to authenticated;
grant execute on function private.is_admin() to authenticated;

Why it matters. The PostgreSQL manual's section on writing SECURITY DEFINER functions safely says search_path “should be set to exclude any schemas writable by untrusted users”, to stop attackers creating objects that mask the ones the function means to use. Supabase adds two rules: set search_path = '' and schema-qualify every name, and never put a security definer function in an exposed schema, because it is callable over the Data API with the creator's privileges. Move it to a schema such as private and revoke execute from public, as above.

Sources: PostgreSQL docs, CREATE FUNCTION (“Writing SECURITY DEFINER Functions Safely”); Supabase docs, Row Level Security (“Use security definer functions”).

4. A secret key behind a NEXT_PUBLIC_ name

What's wrong. The key is only needed on the server, but it's named NEXT_PUBLIC_... because an example or an error message suggested it, or because it made a client-side error go away.

Failing

# .env.local
NEXT_PUBLIC_SUPABASE_SECRET_KEY=sb_secret_placeholder
// lib/supabase/admin.ts
import { createClient } from '@supabase/supabase-js'

export const admin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_SECRET_KEY!
)

Fixed

# .env.local (the secret has no NEXT_PUBLIC_ prefix)
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_placeholder
SUPABASE_SECRET_KEY=sb_secret_placeholder
// lib/supabase/admin.ts
import 'server-only'
import { createClient } from '@supabase/supabase-js'

export const admin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SECRET_KEY!
)

Why it matters. The Next.js docs say a NEXT_PUBLIC_ variable is inlined at build time “into any JavaScript sent to the browser.” Supabase says a secret key “bypasses every Row Level Security policy you have” and should never be put in a browser, a shipped application or source control, and it documents a procedure for rotating a leaked key. If a secret key has already shipped under a public name, treat it as leaked and rotate it, because removing the variable doesn't remove it from bundles that were already served. The server-only import turns an accidental client import into a build error, per the Next.js data security guide. The same rule applies to the legacy service_role key.

Sources: Next.js docs, Environment Variables (“Bundling Environment Variables for the Browser”); Supabase docs, API keys; Next.js docs, Data Security (“Preventing client-side execution of server-only code”).

5. Trusting getSession() on the server

What's wrong. A server component or a proxy decides who the user is from getSession(), which reads the session out of the cookie.

Failing

// app/dashboard/page.tsx
const supabase = await createClient()
const { data: { session } } = await supabase.auth.getSession()
if (!session) redirect('/login')
return <p>Hello {session.user.email}</p>

Fixed

// app/dashboard/page.tsx
const supabase = await createClient()
const { data: { user }, error } = await supabase.auth.getUser()
if (error || !user) redirect('/login')
return <p>Hello {user.email}</p>

Why it matters. Supabase's server-side Auth guide for Next.js says “Anyone can forge the session cookie”, and “Never trust supabase.auth.getSession() inside server code”. It says getSession() isn't revalidated against the Auth server, so the user object it returns shouldn't be trusted for authorization decisions. The getUser() reference says it makes a network request to the Auth server, “so the returned value is authentic and can be used to base authorization rules on.” The same guide now recommends getClaims() to protect pages and user data, which verifies the token's signature, and the reference page says to prefer it over getUser() for speed. Supabase's docs describe either as a way to verify the user on the server. They don't describe getSession() on its own as one.

Sources: Supabase docs, Setting up Server-Side Auth for Next.js (creating a client); Supabase JavaScript reference, getUser; Supabase JavaScript reference, getClaims.

6. A Server Action with no auth check

What's wrong. The page that renders the delete button redirects signed-out visitors, so the author assumes the action is protected too. It isn't.

Failing

// app/actions.ts
'use server'
import { createClient } from '@/lib/supabase/server'

export async function deletePost(postId: string) {
  const supabase = await createClient()
  await supabase.from('posts').delete().eq('id', postId)
}

Fixed

// app/actions.ts
'use server'
import { createClient } from '@/lib/supabase/server'

export async function deletePost(postId: string) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) throw new Error('Unauthorized')

  // RLS should also enforce ownership; the filter makes the intent explicit.
  const { error } = await supabase
    .from('posts')
    .delete()
    .eq('id', postId)
    .eq('author_id', user.id)
  if (error) throw new Error('Delete failed')
}

Why it matters. The Next.js data security guide says an exported Server Action “is reachable via a direct POST request, not just through your application's UI”, that “A page-level authentication check does not extend to the Server Actions defined within it”, and that you should check authorization as well as authentication to avoid Insecure Direct Object Reference (IDOR) bugs. It lists “Is the user re-authorized inside the action?” and “Does the action check ownership of the resource (authorization, not just authentication)?” as audit questions. Keep RLS on as well, so a missed check in one action isn't the only thing between a caller and the table.

Source: Next.js docs, Data Security (“Built-in Server Actions Security features”, “Authentication and authorization” and “Auditing”).

7. A Stripe webhook that skips signature verification

What's wrong. The route parses the body as JSON and trusts it. Anyone who finds the URL can post a checkout.session.completed event and get paid access for nothing. A second version of this bug is subtler: the route does verify the signature but calls request.json() first, so the body no longer matches what Stripe signed and verification fails.

Failing

// app/api/webhooks/stripe/route.ts
export async function POST(request: Request) {
  const event = await request.json() // never verified
  if (event.type === 'checkout.session.completed') {
    const session = event.data.object
    await grantAccess(session.client_reference_id)
  }
  return Response.json({ received: true })
}

Fixed

// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST(request: Request) {
  const signature = request.headers.get('stripe-signature')
  if (!signature) return new Response('Missing signature', { status: 400 })

  const body = await request.text() // raw body, exactly as Stripe sent it

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch {
    return new Response('Invalid signature', { status: 400 })
  }

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object as Stripe.Checkout.Session
    await grantAccess(session.client_reference_id)
  }
  return Response.json({ received: true })
}

Why it matters. Stripe's webhook documentation says that without verification “an attacker could send fake webhook events to your endpoint to trigger actions like fulfilling orders, granting account access, or modifying records.” It says constructEvent() takes the raw request body, the Stripe-Signature header and your endpoint secret, and that the body must be “the exact UTF-8 string Stripe sent, without any modifications”, because frameworks that parse the body first can break verification. The fixed handler still has one gap Stripe's best-practice list calls out: duplicate deliveries. Webhooks “might occasionally receive the same event more than once”, so record the event ID and skip repeats. If you're debugging a verification error, see our guide to Stripe webhook signature verification failing in Next.js.

Source: Stripe docs, Manage webhook endpoints (“Resolve signature verification errors” and “Best practices”).

8. An open redirect in the auth callback

What's wrong. After sign-in, the callback sends the user to wherever a next query parameter says.

Failing

// app/auth/callback/route.ts
import { NextResponse } from 'next/server'

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const next = searchParams.get('next') ?? '/'
  return NextResponse.redirect(next)
}

Fixed

// app/auth/callback/route.ts
import { NextResponse } from 'next/server'

export async function GET(request: Request) {
  const { origin, searchParams } = new URL(request.url)
  const requested = searchParams.get('next') ?? '/'

  // Parse it, then keep it only if it stays on our own origin.
  const target = new URL(requested, origin)
  const destination = target.origin === origin ? target : new URL('/', origin)

  return NextResponse.redirect(destination)
}

Why it matters. The OWASP cheat sheet says an attacker can point an unvalidated redirect at a malicious site to run a phishing scam, and that because the server name in the link matches the original site, “phishing attempts may have a more trustworthy appearance.” MITRE lists it as CWE-601, URL redirection to an untrusted site. OWASP's advice is to use a maintained URL parser and compare the parsed scheme, host and port against an explicit allowlist. The fix above parses the value, redirects to the parsed URL when its origin matches yours and otherwise to /. That also catches tricks like //evil.example and https://yoursite.example@evil.example that a plain string check can miss. It redirects to the parsed URL itself, not to a path rebuilt from it, because rebuilding from pathname turns an input like /.//evil.example into //evil.example, which a browser treats as another site. If your app sits behind a proxy, the origin in request.url may not be your public one, so compare against your configured site URL instead.

Sources: OWASP Cheat Sheet Series, Unvalidated Redirects and Forwards; MITRE, CWE-601.

Check your own app

All eight are checks in our free checker, nextjs-supabase-stripe-security-checker (K2, R1, R2, R4, A1, A2, S1 and S2, A5). For this guide we built a small demo app from adapted versions of the failing snippets above (the webhook route, for example, writes to Supabase instead of calling grantAccess) and ran v0.6.0. It reported 11 findings: nine for the eight bugs above (the webhook route triggers two checks, S1 and S2), plus two more it spotted in the same files (a missing webhook idempotency record and a missing grant). The missing-grant finding refers to a Supabase change dated 30 Oct 2026. In late August a Supabase CLI maintainer wrote (supabase/cli PR #6337) that the earlier default change “never landed” and hosted projects were still auto-granting, and the CLI dropped its own 30 Oct messaging. So the date may move: check what your own project does. After the fixes above it reported one finding, the idempotency gap from section 7. That matches what we said there.

That is a demo, not proof. The checker is a heuristic static scanner. A clean run doesn't mean an app is secure, a finding is a prompt to review, and it only reads your code and SQL files, not your live Supabase project, dashboard policies or Stripe settings. It runs locally, makes no network calls, and is free and MIT-licensed:

npx github:edtrackai/nextjs-supabase-stripe-security-checker /path/to/your/app

The source is on GitHub, and there's more detail on our checker page. On an interactive terminal it prints one line labelled “Ad” about our paid tools. --quiet turns it off, and it never appears in CI or --json output.

Disclosure: the Launch Audit is our own paid product. If you want a deeper pass before launch, the Launch Audit is designed to run 28 checks across keys, auth, RLS and Stripe, with an auditor agent that has no Edit or Write tools. It costs $12 (plus VAT where applicable).

Sources

As of November 2026 (all sources checked 2 Nov 2026):

  • Supabase docs, Row Level Security: https://supabase.com/docs/guides/database/postgres/row-level-security
  • Supabase docs, API keys: https://supabase.com/docs/guides/getting-started/api-keys
  • Supabase docs, Setting up Server-Side Auth for Next.js (creating a client): https://supabase.com/docs/guides/auth/server-side/creating-a-client?queryGroups=framework&framework=nextjs
  • Supabase JavaScript reference, getUser: https://supabase.com/docs/reference/javascript/auth-getuser
  • Supabase JavaScript reference, getClaims: https://supabase.com/docs/reference/javascript/auth-getclaims
  • PostgreSQL docs, CREATE FUNCTION (Writing SECURITY DEFINER Functions Safely): https://www.postgresql.org/docs/current/sql-createfunction.html
  • Next.js docs, Environment Variables: https://nextjs.org/docs/app/guides/environment-variables
  • Next.js docs, Data Security: https://nextjs.org/docs/app/guides/data-security
  • Stripe docs, Manage webhook endpoints: https://docs.stripe.com/webhooks/signature
  • OWASP Cheat Sheet Series, Unvalidated Redirects and Forwards: https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html
  • MITRE CWE-601, URL Redirection to Untrusted Site (Open Redirect): https://cwe.mitre.org/data/definitions/601.html
  • Stack & Ship, nextjs-supabase-stripe-security-checker (v0.6.0, MIT): https://github.com/edtrackai/nextjs-supabase-stripe-security-checker

Supabase, PostgreSQL, Next.js, Stripe, OWASP, MITRE, GitHub and other names belong to their owners. Names are used only to identify products and services. Stack & Ship is not affiliated with or endorsed by any of them.