| name | react-security |
| description | React and Next.js specific security patterns and vulnerability prevention. Use when auditing or securing React/Next.js applications — covers JSX escaping limits, dangerouslySetInnerHTML, Server Actions, API routes, middleware auth, SSR data exposure, and hydration risks. |
| license | MIT |
| metadata | {"author":"pragma-frontend-security","version":"1.0","framework":"React 18+ / Next.js 14+"} |
React & Next.js Security Patterns
Security rules specific to React and Next.js applications. Complements the shared frontend-security skill with React-specific APIs, Server Component boundaries, and Next.js-specific attack vectors.
R-S1 — JSX Auto-Escaping & Its Limits
React's JSX escapes values by default, but there are gaps.
What JSX Protects
<p>{userInput}</p>
<div title={userInput} />
Where JSX Does NOT Protect
<div dangerouslySetInnerHTML={{ __html: userInput }} />
<a href={userInput}>Click</a>
React.createElement(userInput, props);
<div {...userControlledProps} />
<script dangerouslySetInnerHTML={{
__html: `window.__DATA__ = ${JSON.stringify(serverData)}`
// If serverData contains </script>, it breaks out of the tag
}} />
ALWAYS Do
import DOMPurify from 'dompurify';
function SafeHtml({ html }: { html: string }) {
return (
<div
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li'],
ALLOWED_ATTR: ['href', 'target', 'rel'],
}),
}}
/>
);
}
function SafeLink({ href, children }: { href: string; children: React.ReactNode }) {
const isSafe = /^(https?:|mailto:|\/(?!\/))/.test(href);
return <a href={isSafe ? href : '#'}>{children}</a>;
}
function SafeComponent({ className, id, ...rest }: SafeComponentProps) {
return <div className={className} id={id} />;
}
<script dangerouslySetInnerHTML={{
__html: `window.__DATA__ = ${JSON.stringify(serverData).replace(/</g, '\\u003c')}`
}} />
R-S2 — Server Components & Data Exposure (Next.js)
Server Components run on the server. But be careful what data you pass to Client Components.
NEVER Do
import { ClientDashboard } from './ClientDashboard';
export default async function Page() {
const user = await db.users.findOne({ id: session.userId });
return <ClientDashboard user={user} />;
}
return <AdminPanel permissions={internalPermissions} />;
'use client';
import { db } from '@/lib/database';
ALWAYS Do
export default async function Page() {
const user = await db.users.findOne({ id: session.userId });
const clientUser = {
name: user.name,
avatar: user.avatar,
};
return <ClientDashboard user={clientUser} />;
}
import 'server-only';
import { PrismaClient } from '@prisma/client';
export const db = new PrismaClient();
interface ClientUser {
name: string;
avatar: string;
}
R-S3 — Server Actions Security (Next.js 14+)
Server Actions are public HTTP endpoints. Treat them like API routes.
NEVER Do
'use server';
export async function deleteUser(userId: string) {
await db.users.delete({ where: { id: userId } });
}
'use server';
export async function updateProfile(formData: FormData) {
const role = formData.get('role') as string;
await db.users.update({
where: { id: session.userId },
data: { role },
});
}
'use server';
export async function getUser(id: string) {
return await db.users.findOne({ id });
}
ALWAYS Do
'use server';
import { auth } from '@/lib/auth';
import { z } from 'zod';
const UpdateProfileSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
});
export async function updateProfile(formData: FormData) {
const session = await auth();
if (!session?.user) {
throw new Error('Unauthorized');
}
const result = UpdateProfileSchema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
});
if (!result.success) {
return { error: 'Invalid input' };
}
await db.users.update({
where: { id: session.user.id },
data: result.data,
});
return { success: true };
}
import { createSafeActionClient } from 'next-safe-action';
const actionClient = createSafeActionClient({
handleServerError(error) {
return { error: 'Something went wrong' };
},
});
export const updateProfile = actionClient
.schema(UpdateProfileSchema)
.action(async ({ parsedInput, ctx }) => {
});
R-S4 — API Routes Security (Next.js)
NEVER Do
export async function DELETE(request: Request) {
const { userId } = await request.json();
await db.users.delete({ where: { id: userId } });
return Response.json({ ok: true });
}
export async function GET() {
const users = await db.users.findMany();
return Response.json(users);
}
const { search } = await request.json();
const users = await db.$queryRaw`SELECT * FROM users WHERE name = ${search}`;
ALWAYS Do
import { auth } from '@/lib/auth';
import { z } from 'zod';
const ParamsSchema = z.object({
id: z.string().uuid(),
});
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const session = await auth();
if (!session?.user) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
const result = ParamsSchema.safeParse(params);
if (!result.success) {
return Response.json({ error: 'Invalid request' }, { status: 400 });
}
if (session.user.id !== result.data.id && session.user.role !== 'admin') {
return Response.json({ error: 'Forbidden' }, { status: 403 });
}
const user = await db.users.findUnique({
where: { id: result.data.id },
select: { id: true, name: true, email: true, avatar: true },
});
return Response.json(user);
}
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '60s'),
});
export async function POST(request: Request) {
const ip = request.headers.get('x-forwarded-for') ?? '127.0.0.1';
const { success } = await ratelimit.limit(ip);
if (!success) {
return Response.json({ error: 'Too many requests' }, { status: 429 });
}
}
R-S5 — Middleware Authentication (Next.js)
NEVER Do
export function middleware(request: NextRequest) {
if (request.cookies.get('token')) {
return NextResponse.next();
}
}
ALWAYS Do
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const PUBLIC_ROUTES = ['/login', '/register', '/forgot-password', '/'];
const AUTH_ROUTES = ['/login', '/register'];
export function middleware(request: NextRequest) {
const token = request.cookies.get('session')?.value;
const { pathname } = request.nextUrl;
if (PUBLIC_ROUTES.includes(pathname)) {
if (token && AUTH_ROUTES.includes(pathname)) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
return NextResponse.next();
}
if (!token) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('returnUrl', pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|public).*)'],
};
R-S6 — Hooks & State Security
NEVER Do
const [user, setUser] = useState({
name: 'John',
ssn: '123-45-6789',
creditCard: '4111...',
});
const [searchParams, setSearchParams] = useSearchParams();
searchParams.set('token', authToken);
<AuthContext.Provider value={{ user, secretKey, refreshToken }}>
{children} {/* Every child can read secretKey */}
</AuthContext.Provider>
ALWAYS Do
const [user, setUser] = useState({
name: 'John',
avatar: '/img/john.png',
});
useEffect(() => {
return () => {
setSensitiveFormData(null);
};
}, []);
const transientToken = useRef<string | null>(null);
interface AuthContextType {
isAuthenticated: boolean;
userName: string;
logout: () => Promise<void>;
}
R-S7 — Security Headers in Next.js
import type { NextConfig } from 'next';
const securityHeaders = [
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'nonce-{nonce}'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
`connect-src 'self' ${process.env.NEXT_PUBLIC_API_URL ?? ''}`,
"frame-ancestors 'none'",
"form-action 'self'",
"base-uri 'self'",
"upgrade-insecure-requests",
].join('; '),
},
{ 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 config: NextConfig = {
async headers() {
return [
{
source: '/(.*)',
headers: securityHeaders,
},
];
},
poweredByHeader: false,
};
export default config;
React / Next.js Security Checklist
| Category | Check | Severity |
|---|
| XSS | No dangerouslySetInnerHTML without DOMPurify | CRITICAL |
| XSS | Validate href props against javascript: protocol | CRITICAL |
| XSS | No unvalidated prop spreading ({...userProps}) | HIGH |
| SSR | Server Components don't pass sensitive data to Client Components | CRITICAL |
| SSR | Use server-only package for server-exclusive modules | HIGH |
| SSR | Escape data embedded in <script> tags during SSR | HIGH |
| Actions | Server Actions authenticate + validate + authorize | CRITICAL |
| Actions | Server Actions never return full DB objects | HIGH |
| API Routes | Every route validates input with Zod | HIGH |
| API Routes | Rate limiting on public/sensitive endpoints | HIGH |
| API Routes | Return only necessary fields (select/DTO) | HIGH |
| Middleware | Light validation only, full auth in route handlers | HIGH |
| Middleware | Protected routes require session, public routes listed | HIGH |
| State | No PII/secrets in useState, useContext, or URL params | HIGH |
| State | Sensitive data cleared on unmount | MEDIUM |
| Headers | CSP, HSTS, X-Frame-Options, X-Content-Type-Options set | HIGH |
| Headers | poweredByHeader: false in next.config | LOW |
| Deps | server-only enforced on DB/secret modules | HIGH |