Dev.to Security 🔐 Cybersecurity 👁 0 📖 4 min read

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 secur

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: [email protected]

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.