Building fast with modern AI coding assistants makes shipping fullstack web apps effortless. However, code generators and tutorials often produce code that passes local tests but suffers from critical architectural security vulnerabilities in production.
When building multi-tenant SaaS applications with Next.js App Router and Supabase, developers frequently encounter subtle security flaws that allow unauthorized users to view private database records.
In this guide, we walk through three common vulnerability scenarios, provide reproducible code examples, demonstrate secure implementation patterns, and cite official framework specifications.
Code Implementation Disclaimer: The reference implementation patterns and code snippets in this guide have been statically reviewed against official framework specifications (@supabase/ssr 0.5.x and Next.js 14/15 App Router) rather than executed as live automated test suites. Always verify integration behavior within your project's specific dependency versions and deployment environment.
Vulnerability Scenario 1: The "auth.uid() IS NOT NULL" Trap
When developers first enable PostgreSQL Row-Level Security (RLS), tutorials frequently suggest a policy like this:
-- VULNERABLE RLS POLICY
CREATE POLICY "Allow authenticated users to read records"
ON public.customer_records
FOR SELECT
TO authenticated
USING (auth.uid() IS NOT NULL);
Why It Fails:
This policy only checks whether the client has presented a valid JWT token. It does NOT verify that the authenticated user owns the specific row being requested.
If User A logs in and runs supabase.from('customer_records').select('*'), PostgreSQL evaluates auth.uid() IS NOT NULL to true for every single row in the table. User A can now read all customer records belonging to User B, User C, and every other tenant.
The Secure Implementation Pattern:
A secure RLS policy must explicitly check that the record belongs to the requester:
-- SECURE RLS POLICY: Direct Tenant Ownership
CREATE POLICY "Users can only read own records"
ON public.customer_records
FOR SELECT
TO authenticated
USING (auth.uid() = user_id);
For team or organization-based workspaces, join through a validated membership table:
-- SECURE RLS POLICY: Workspace Membership
CREATE POLICY "Members can view workspace records"
ON public.workspace_documents
FOR SELECT
TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.workspace_memberships
WHERE workspace_memberships.workspace_id = workspace_documents.workspace_id
AND workspace_memberships.user_id = auth.uid()
)
);
Vulnerability Scenario 2: Shared Cache & Set-Cookie Leaks in Next.js App Router
In Next.js 14 and 15, route handlers and Server Components cache aggressively by default. If your middleware or route handler refreshes Supabase session tokens, an intermediate proxy or CDN might cache the response headers, including Set-Cookie.
Why It Fails:
When User A visits /dashboard, the server generates a new session token and returns a Set-Cookie: sb-<project_ref>-auth-token=... header. If the response headers lack explicit cache privacy directives, a shared edge cache (e.g., Cloudflare, CloudFront, or Vercel Edge Cache) may cache that response.
When User B visits the same URL, the edge cache serves the cached response along with User A's session cookie. User B is now authenticated as User A.
The Secure Implementation Pattern:
Any endpoint handling authentication credentials or user-specific data must declare strict private caching directives:
// app/api/auth/callback/route.ts
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
// Authentication logic...
const response = NextResponse.redirect(new URL('/dashboard', request.url));
// Enforce private cache boundary
response.headers.set('Cache-Control', 'private, no-cache, no-store, must-revalidate');
response.headers.set('Pragma', 'no-cache');
response.headers.set('Expires', '0');
return response;
}
Vulnerability Scenario 3: Stripe Webhook HMAC Mutation
When integrating payment webhooks, developers frequently parse incoming JSON requests using standard body parsers:
// VULNERABLE WEBHOOK HANDLER
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
export async function POST(request: Request) {
const body = await request.json(); // BUG: Mutates signed byte order
const sig = request.headers.get('stripe-signature')!;
// Fails or behaves inconsistently:
const event = stripe.webhooks.constructEvent(JSON.stringify(body), sig, secret);
}
Why It Fails:
Stripe signs the raw request payload bytes. Calling request.json() and re-stringifying it mutates whitespace, property key ordering, and floating-point number representations. This causes HMAC verification to fail intermittently.
The Secure Implementation Pattern:
Always read the unparsed string buffer before HMAC verification:
// SECURE WEBHOOK HANDLER
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2023-10-16' });
export async function POST(request: Request) {
const rawBody = await request.text(); // PRESERVES EXACT SIGNED BYTES
const signature = request.headers.get('stripe-signature');
if (!signature) {
return new Response('Missing Stripe signature header', { status: 400 });
}
try {
const event = stripe.webhooks.constructEvent(
rawBody,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
// Process verified event idempotently...
return NextResponse.json({ received: true });
} catch (err: any) {
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
}
}
Automated Verification Script
Run this quick audit in your project root to check for common security configuration issues:
# 1. Detect leaked secrets in public environment variables
grep -rnE "NEXT_PUBLIC_.*(SECRET|PRIVATE_KEY|SERVICE_ROLE)" . \
--include="*.env*" --include="*.ts" --include="*.tsx" --include="*.js"
# 2. Check for raw JSON parsing inside Stripe webhook handlers
grep -rn "request.json()" app/api/webhooks/ \
&& echo "WARNING: Stripe webhooks must read request.text(), not request.json()"
# 3. Detect broad RLS policies that check auth without tenant filtering
grep -rnE "USING\s*\(\s*auth\.uid\(\)\s+IS\s+NOT\s+NULL\s*\)" . \
--include="*.sql" && echo "WARNING: Policy checks login, but not tenant row ownership"
Complete 18-Point Production Checklist & Open Runbook
These three scenarios are part of an 18-point production invariant checklist developed to audit multi-tenant web applications under the REST Personal Economic Operating System.
The complete 18-point runbook and automated bash audit script are available as an open, free resource:
Download the Free 18-Point Production Security Runbook
100% free open developer resource (EUR 0.00). No email signup or credit card required.
Published by Tori under the REST Personal Economic Operating System.
Questions or feedback: hello@ticassociation.com
Top comments (3)
The disclaimer is the part most posts leave out, and scenario 1 is exactly the one that's cheap to make executable. The whole point of the
auth.uid() IS NOT NULLtrap is that it passes a linter and a code read — the only thing that proves the fix is a request made as the wrong user. Minimal harness:And a catalog assertion next to it so an edit to the policy can't silently regress it:
Empty result = no unscoped read policy; the two together turn scenario 1 from "statically reviewed" into "red before, green after." I packaged that exact pair for the customer_records/profiles shape — two accounts,
npm ci && npm test, no cloud project — if you want scenario 1 to carry a live test: github.com/cekuu35/supabase-rls-le...Thanks for this excellent addition, Cenk!
Your minimal test harness hits the nail on the head. The core danger of the
auth.uid() IS NOT NULLanti-pattern is that it gives teams false confidence: it passes lint checks, returns expected data during solo developer smoke testing, and looks completely benign in code reviews.Testing cross-tenant isolation by explicitly querying user A's record with user B's session token is the only deterministic way to catch it before production.
I especially like your catalog query against
pg_policies. Having an automated assertion in CI that flags any public table policy without an explicituid()qualification prevents quiet regressions when schema migrations get merged.Checked out your repo (
supabase-rls-leak-demo) as well. Clean setup and very helpful for anyone wanting a runnable reproduction. Appreciate you sharing it here!Thanks for reading it that closely, and for the repo check — glad the setup ran clean. The CI assertion you describe is the part I'd most like to see become default, because the silent version is what ships. Two things to watch when you wire it: the sweep should also catch policies with no TO clause, since those apply to public and the catalog shows roles = {public} rather than {anon}, so a regex on anon alone misses them; and auth.uid() is not null passes a uid check while scoping nothing, so the filter is better stated as "does it still return rows for a second account", which the two-account test proves and the string can't. Thanks again.