Back to Blog

12 Practical Tips to Protect Your Next.js and React App from Getting Hacked

SecurityNext.jsReactWeb Development

Next.js gives you a lot of security for free: React escapes output by default, server code stays on the server, and the framework ships sensible defaults. But "secure by default" isn't "secure no matter what." Most breaches I see in React and Next.js apps come from a handful of repeat mistakes.

Here's the checklist I run through on every Next.js project I build or audit.

1. Patch Next.js and React quickly

Framework vulnerabilities are real and they get exploited fast. Two recent examples:

  • CVE-2025-29927 (March 2025): attackers could skip Next.js middleware entirely by sending a crafted x-middleware-subrequest header. Any app that did its auth checks only in middleware was wide open.
  • CVE-2025-55182, "React2Shell" (December 2025): a critical remote code execution bug in how React Server Components decode requests, affecting Next.js App Router apps. Follow-up advisories shipped more fixes in the weeks after, so the first patch wasn't the last.

What to do:

# See what you're running
npm ls next react react-dom

# Update to the latest patched release
npm install next@latest react@latest react-dom@latest

Turn on Dependabot or Renovate, and subscribe to the Next.js security advisories so you hear about issues the day they're published, not weeks later.

2. Never rely on middleware alone for authentication

Middleware (renamed proxy in Next.js 16) is a great place for redirects and coarse checks, but CVE-2025-29927 showed why it can't be your only lock. Check authorization where the data is accessed: in Server Components, Route Handlers and Server Actions.

A simple pattern is a small data-access layer that always verifies the session:

// lib/dal.ts
import "server-only";
import { cache } from "react";
import { redirect } from "next/navigation";
import { getSession } from "./session";

export const requireUser = cache(async () => {
  const session = await getSession();
  if (!session?.userId) redirect("/login");
  return session;
});

Every page, action and API route that touches private data calls requireUser() first.

3. Treat every Server Action as a public API endpoint

This is the most misunderstood part of the App Router. A Server Action looks like a normal function, but Next.js exposes it as an HTTP endpoint that anyone can call directly with any arguments they like, whether or not your UI shows the button.

So every action needs to:

  1. Authenticate the user.
  2. Validate the input.
  3. Check the user is allowed to touch that specific record.
"use server";

import { z } from "zod";
import { requireUser } from "@/lib/dal";
import { db } from "@/lib/db";

const schema = z.object({
  invoiceId: z.string().uuid(),
  note: z.string().max(500),
});

export async function updateInvoiceNote(input: unknown) {
  const user = await requireUser();
  const { invoiceId, note } = schema.parse(input);

  // Ownership check: without this, anyone can edit anyone's invoice
  // just by changing the ID (an "IDOR" vulnerability).
  const invoice = await db.invoice.findFirst({
    where: { id: invoiceId, ownerId: user.userId },
  });
  if (!invoice) throw new Error("Not found");

  await db.invoice.update({ where: { id: invoiceId }, data: { note } });
}

Never trust hidden form fields, IDs in the URL, or a "role" sent from the browser.

4. Keep secrets out of the browser

Three common leaks:

  • The NEXT_PUBLIC_ prefix. Any environment variable starting with NEXT_PUBLIC_ is bundled into client JavaScript and visible to everyone. API keys for OpenAI, Stripe secret keys and database URLs must never have this prefix.
  • Passing whole objects to Client Components. Props sent from a Server Component to a Client Component are serialized into the page. Passing a full user record can leak password hashes, internal flags or tokens. Pass only the fields the UI needs.
  • Server code imported by accident. Add import "server-only" at the top of files that use secrets. The build then fails if a Client Component ever imports them.

5. Be careful with dangerouslySetInnerHTML

React escapes text automatically, which blocks most cross-site scripting (XSS). dangerouslySetInnerHTML switches that protection off. If the HTML comes from users, a CMS or an AI model, sanitize it first:

import DOMPurify from "isomorphic-dompurify";

export function RichText({ html }: { html: string }) {
  return (
    <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />
  );
}

A subtle one: JSON-LD structured data. JSON.stringify does not escape </script>, so user-controlled text inside your schema can break out of the script tag. Escape the < character:

<script
  type="application/ld+json"
  dangerouslySetInnerHTML={{
    __html: JSON.stringify(schema).replace(/</g, "\\u003c"),
  }}
/>

Also validate user-supplied links. Only allow http: and https: URLs in href, never javascript: or data:.

6. Add security headers and a Content Security Policy

Headers are cheap, and they stop whole classes of attacks. Add them in next.config.ts:

const securityHeaders = [
  { key: "X-Content-Type-Options", value: "nosniff" },
  { key: "X-Frame-Options", value: "DENY" },
  { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
  { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
  { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
];

const nextConfig = {
  poweredByHeader: false,
  async headers() {
    return [{ source: "/(.*)", headers: securityHeaders }];
  },
};

A Content Security Policy (CSP) is the strongest defence against XSS because it tells the browser which scripts are allowed to run. Start with Content-Security-Policy-Report-Only so you can see what would break, then enforce it. The Next.js docs have a nonce-based setup for App Router apps.

7. Rate-limit anything that can be abused

Login forms, sign-up, password reset, contact forms and especially AI endpoints (where every request costs you money) need rate limits. Without them you invite brute-force attacks, spam and surprise API bills.

Use your platform's firewall rules or a simple Redis-backed limiter keyed by IP address and user ID. Add a CAPTCHA or bot check on public forms.

8. Handle sessions and cookies properly

  • Store session tokens in cookies with httpOnly, secure and sameSite: "lax" (or "strict").
  • Don't put tokens in localStorage. Any XSS bug can read it.
  • Give sessions an expiry, and make logout actually invalidate the session on the server.
  • Prefer a mature auth library over hand-rolled JWT logic.

9. Validate redirects

Login flows often redirect to ?next=/dashboard after sign-in. If you redirect to whatever value is passed, attackers can send users to a phishing site through your trusted domain. This is called an open redirect.

function safeRedirect(target: string | null) {
  // Only allow relative paths on this site, not "//evil.com"
  if (!target || !target.startsWith("/") || target.startsWith("//")) {
    return "/dashboard";
  }
  return target;
}

10. Lock down remote images and server-side fetches

  • In next.config.ts, list the exact hosts in images.remotePatterns. Avoid wildcard patterns that let anyone use your server as an image proxy.
  • If your server fetches a URL the user provides (link previews, webhooks, "import from URL"), block internal addresses like localhost, 169.254.169.254 and private IP ranges. Otherwise attackers can reach your internal network or cloud metadata. This attack is called SSRF (server-side request forgery).

11. Don't leak errors, and escape user input everywhere it goes

  • Return generic error messages to the browser and log the details on the server. Stack traces and database errors are a gift to attackers.
  • User input isn't only dangerous in React. Escape it in HTML emails (contact forms are a classic spot), use parameterized queries or an ORM for SQL, and never build shell commands from user input.

12. Take supply-chain attacks seriously

Your app is only as secure as its node_modules. In 2025, self-spreading malware compromised popular npm packages and stole developer tokens.

  • Commit your lockfile and install with npm ci in CI.
  • Run npm audit regularly and fix high-severity issues.
  • Remove dependencies you don't use; every package is an attack surface.
  • Be suspicious of brand-new packages with names similar to popular ones.
  • Protect your npm and GitHub accounts with two-factor authentication.

Quick checklist

  • Next.js and React on the latest patched versions
  • Authorization checked in every Server Component, Route Handler and Server Action
  • All Server Action input validated, with ownership checks on every record
  • No secrets with NEXT_PUBLIC_; server-only on sensitive modules
  • Sanitized HTML, escaped JSON-LD, validated links
  • Security headers and a CSP
  • Rate limits on auth, forms and AI endpoints
  • httpOnly secure cookies; no tokens in localStorage
  • Safe redirects and locked-down remote fetches
  • Generic error messages; escaped emails
  • Audited, minimal dependencies

Security isn't a one-time task, but this list covers the mistakes behind most real-world incidents I see. If you'd like a second pair of eyes on your Next.js app, get in touch.