Skip to content

Next Security Hardening

Server Components are secure by default. Client Components leak everything you pass to them. The render tree doesn't care about your secrets - it renders whatever you give it and ships it to the browser

Next.js improves security over plain React because more code runs server-side. But framework-level security doesn't fix application-level mistakes. You can still leak data , miss CSRF , forget CSP headers , and expose admin routes. The framework gives you tools - you have to use them

Server Components - the security boundary

Server Components never ship to the client. Their imports , data fetching , and logic stay on the server

// This is SECURE - all server-side
// src/app/page.tsx (Server Component)
import { getSecretData } from '@/lib/db'

export default async function Page() {
  const data = await getSecretData() // never reaches client
  return <div>{data.publicField}</div> // only public field rendered
}

The danger: passing server data to a Client Component

// This is DANGEROUS - server data passed to client
// src/app/page.tsx
import { getSecretData } from '@/lib/db'
import ClientWidget from './ClientWidget'

export default async function Page() {
  const data = await getSecretData()
  // data contains password_hash , ssn , internal notes
  return <ClientWidget data={data} /> // ALL of data ships to client!
}
// ClientWidget.tsx
'use client'

export default function ClientWidget({ data }: { data: any }) {
  // Data includes password_hash!
  // Available in browser devtools , React DevTools , network tab
  return <div>{data.name}</div>
}

The fix: never pass full database objects to Client Components. Extract only what the client needs

// Secure version - pass only what's needed
export default async function Page() {
  const data = await getSecretData()
  return <ClientWidget name={data.name} avatar={data.avatar} />
}

CSRF protection

Server Actions: Built-in CSRF protection. Next.js generates a crypto-signed token and validates it on every action call

Route handlers: NO built-in CSRF protection. If you use cookie auth with route handlers , you need SameSite cookies or custom CSRF tokens

// Route handler with SameSite protection
// src/app/api/transfer/route.ts
export async function POST(request: Request) {
  // Check Origin/Referer headers manually
  const origin = request.headers.get('origin')
  const referer = request.headers.get('referer')
  const allowedOrigin = process.env.APP_URL

  if (origin && !origin.startsWith(allowedOrigin)) {
    return Response.json({ error: 'Cross-origin request blocked' }, { status: 403 })
  }

  if (referer && !referer.startsWith(allowedOrigin)) {
    return Response.json({ error: 'Invalid referer' }, { status: 403 })
  }

  // Process the request
  const body = await request.json()
  // ... transfer logic ...
}

SameSite cookie configuration:

// In your auth setup or route handler
response.cookies.set('session', token, {
  httpOnly: true,      // not accessible via JavaScript
  secure: true,        // HTTPS only
  sameSite: 'lax',     // or 'strict' for maximum protection
  path: '/'
})

SameSite=Strict prevents cookies from being sent on any cross-site request. SameSite=Lax allows cookies on top-level navigations (standard OAuth flows need this). SameSite=None disables SameSite - only use this with Secure flag and explicit CSRF tokens

XSS in Server vs Client Components

Server Components render HTML strings that are escaped by default. No XSS via data interpolation

// Server Component - safe by default
export default async function Page() {
  const userInput = '<script>alert("xss")</script>'
  return <div>{userInput}</div>
  // Renders: <div>&lt;script&gt;alert("xss")&lt;/script&gt;</div>
  // Safe. Escaped.
}

Client Components with dangerouslySetInnerHTML are NOT safe:

'use client'

export default function RichContent({ html }: { html: string }) {
  return <div dangerouslySetInnerHTML={{ __html: html }} />
  // If html contains <script> tags , they execute
  // Sanitize with DOMPurify before rendering
}
// Safe version
import DOMPurify from 'isomorphic-dompurify'

export default function RichContent({ html }: { html: string }) {
  const sanitized = DOMPurify.sanitize(html)
  return <div dangerouslySetInnerHTML={{ __html: sanitized }} />
}

data leaks prevention

Checklist for preventing data leaks:

  • Never pass full DB objects to Client Components - extract specific fields
  • NEXT_PUBLIC_ prefix audit - anything with this prefix ships to the browser. API keys , secrets , database URLs should never have this prefix
  • Bundle analysis - run next build and inspect the bundle. If you see server-only code in the client bundle , you imported it wrong
  • Console.log in Server Components - server logs are fine. Client Component server logs are NOT - they print in the browser console
  • Error messages - Server Component errors show in development. In production , customize the error page to avoid leaking stack traces
// src/app/error.tsx
'use client'

export default function ErrorPage({ error }: { error: Error }) {
  // Don't show error.message to users - it might contain SQL , paths , or secrets
  return (
    <div>
      <h2>Something went wrong</h2>
      <p>Our team has been notified. Try again later</p>
    </div>
  )
}

CSP headers

Content Security Policy headers prevent XSS by telling the browser what sources are trusted

// src/middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  const response = NextResponse.next()

  response.headers.set(
    'Content-Security-Policy',
    [
      "default-src 'self'",
      "script-src 'self' 'unsafe-eval' 'unsafe-inline'", // unsafe-inline needed for Next.js
      "style-src 'self' 'unsafe-inline'",
      "img-src 'self' blob: data:",
      "font-src 'self'",
      "connect-src 'self' https://api.example.com",
      "frame-src 'none'",
      "object-src 'none'"
    ].join('; ')
  )

  return response
}

Or set CSP in next.config.ts:

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          {
            key: 'Content-Security-Policy',
            value: "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'"
          },
          {
            key: 'X-Frame-Options',
            value: 'DENY'
          },
          {
            key: 'X-Content-Type-Options',
            value: 'nosniff'
          },
          {
            key: 'Referrer-Policy',
            value: 'strict-origin-when-cross-origin'
          }
        ]
      }
    ]
  }
}

export default nextConfig

CSP is one of those things every security guide mentions and most production apps don't have. Be the exception. Add it

prerequisites

next_09_auth.md - you should understand authentication flows and session handling


next → next_11_middleware.md