DEV Community

TIC
TIC

Posted on

3 Production Security Pitfalls in Next.js App Router (And How to Fix Them)

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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()
  )
);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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 });
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
cekuu35 profile image
Cenk KURTOĞLU •

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 NULL trap 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:

// using userB's session, request a row whose user_id is A
const { data, error } = await userB
  .from('customer_records').select('*').eq('id', aRowId);
expect(data).toEqual([]);   // a non-empty array here is the bug, whatever the policy is named
Enter fullscreen mode Exit fullscreen mode

And a catalog assertion next to it so an edit to the policy can't silently regress it:

select tablename, policyname, cmd, qual
from pg_policies
where schemaname = 'public' and cmd in ('SELECT','ALL')
  and (qual is null or qual !~ 'uid\(\)');
Enter fullscreen mode Exit fullscreen mode

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...

Collapse
 
toritic profile image
TIC •

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 NULL anti-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 explicit uid() 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!

Collapse
 
cekuu35 profile image
Cenk KURTOĞLU •

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.