Table of contents
Open Table of contents
- Introduction
- The Old Stuff That Still Works
- The New Stuff — What Changed When Frameworks Got Server-Side
- Content Security Policy — Your Last Line of Defense
- Supply Chain Attacks — The Problem You’re Probably Not Thinking About
- Security Headers Checklist
- Quick-Check: The Questions Worth Asking Your Codebase
- FAQ
- Conclusion
Introduction
Most security content is written for backend engineers, it talks about SQL injection, server misconfigurations, and authentication flows. Frontend gets a paragraph at the end about input sanitization, and that’s it. That used to be fine, the frontend was dumb — it rendered HTML, maybe ran some jQuery and the serious logic lived elsewhere.
That’s not what frontend means anymore. In 2026, frontend security is a first-class concern: React apps handle authentication, session management, business logic, and direct database access through Server Actions. The attack surface is not smaller than the backend. In some ways it’s bigger, because it’s public-facing by definition and the security habit’s built up around it are still catching up.
This guide covers what actually gets frontend apps hacked — the old stuff that still works, and the new class of vulnerabilities that showed up the moment frameworks started owning both the client and the server.
The Old Stuff That Still Works
Let’s not skip the classics. They’re classic because they keep working.
XSS — Cross-Site Scripting
XSS happens when user input gets rendered as code instead of text. The browser can’t tell the difference between your script and an attacker’s and it just runs whatever shows up.
React’s JSX escapes output by default, which catches the most obvious cases. The footgun is dangerouslySetInnerHTML:
// This will execute whatever user input contains
function Comment({ content }: { content: string }) {
return <div dangerouslySetInnerHTML={{ __html: content }} />;
}BadComponent.tsx
If content comes from user input even through multiple layers and you don’t sanitize it first, you have XSS.
The fix is DOMPurify which strips dangerous tags before they hit the DOM:
import DOMPurify from "dompurify";
function Comment({ content }: { content: string }) {
const clean = DOMPurify.sanitize(content);
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}SafeComment.tsx
Beyond React, XSS can still sneak in through:
innerHTMLassignments in vanilla JS helpers- Template literals building HTML strings
- URL parameters rendered without encoding
- Third-party widgets that inject script tags
CSRF — Cross-Site Request Forgery
CSRF tricks a logged-in user’s browser into making a state-changing request on your behalf. If your cookies are SameSite=None or if you’re not checking origins, a malicious page can POST to your API as if it were the user.
Next.js Server Actions protect against this automatically — they compare the Origin header against the Host header and reject mismatches. But if you’re still using custom API Routes that modify state, you need to handle this yourself:
export async function POST(req: Request) {
const origin = req.headers.get("origin");
const host = req.headers.get("host");
// Skipping this check means CSRF is possible
if (!origin || !origin.includes(host ?? "")) {
return Response.json({ error: "Forbidden" }, { status: 403 });
}
}api/transfer/route.ts
And your session cookies should always have these attributes:
cookieStore.set("session", token, {
httpOnly: true, // not readable by JS
secure: true, // HTTPS only
sameSite: "lax", // blocks cross-site POST
path: "/",
maxAge: 60 * 60 * 24,
});setSessionCookie.ts
SameSite=Lax is the minimum. Strict is better for admin tools.
The localStorage Token Problem
A lot of tutorials still tell you to store JWTs in localStorage. The reasoning is usually “it’s simpler.” The problem is that any XSS on your domain — yours, a CDN’s, a third-party widget’s can read localStorage and steal the token.
httpOnly cookies aren’t accessible to JavaScript at all. That’s the whole point. An attacker with XSS execution can’t steal what they can’t read.
| Storage | XSS accessible | CSRF risk | Verdict |
|---|---|---|---|
localStorage | Yes | No | Avoid for auth |
sessionStorage | Yes | No | Avoid for auth |
httpOnly | No | Yes (mitigated by SameSite) | Prefer this |
The New Stuff — What Changed When Frameworks Got Server-Side
This is where things get genuinely interesting, and where most teams are flying blind.
CVE-2025-29927 — The Next.js Middleware Bypass Vulnerability
In March 2025, a CVSS 9.1 vulnerability was disclosed in Next.js. The attack was embarrassingly simple: add one header to any request and every middleware check disappears.
curl -H "x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware" \
https://your-app.com/dashboard
The dashboard loads without authentication. No redirect. Nothing.
The root cause: Next.js used an internal header to prevent middleware from calling it’self in infinite loops. By including that header in a request, an attacker convinced the framework the middleware had already run. The fix was in versions 12.3.5, 13.5.9, 14.2.25, and 15.2.3.
Postmortem on Next.js Middleware bypass
If someone bypasses your middleware by CVE, misconfiguration, or future bug and every route just trusts that auth has already happened, your entire application is open.
The rule: authenticate in every Route Handler and Server Action, regardless of what middleware did:
import { getSession } from "@/lib/session";
export async function POST(req: Request) {
// Always validate here, even though middleware already checked
const session = await getSession();
if (!session?.userId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
}app/api/account/route.ts
Think of middleware as the front door of a building — it checks IDs as a first pass, but every room inside still needs it’s own lock.
Server Actions — The New Attack Surface
Server Actions are convenient. You call a function from your React component and server-side logic runs. No API route to write and no fetch to configure.
A Server Action is effectively just a POST endpoint, and it’s input is not enforced or validated by TypeScript — it can contain anything the caller provides:
"use server";
// Trusts that formData is what you expect
export async function updateProfile(formData: FormData) {
const userId = formData.get("userId");
const role = formData.get("role"); // an attacker can send "admin" here
await db.users.update({ where: { id: userId }, data: { role } });
}actions/updateProfile.ts
Server Actions need the same validation you’d apply to any API endpoint:
"use server";
import { z } from "zod";
import { getSession } from "@/lib/session";
const schema = z.object({
role: z.enum(['user', '...']).optional(),
});
export async function updateProfile(formData: FormData) {
const session = await getSession();
if (!session?.userId) {
throw new Error("Unauthorized");
}
// Validate and type the input
const parsed = schema.safeParse({
role: formData.get("role"),
});
if (!parsed.success) {
throw new Error("Invalid input");
}
// Use session, not user-supplied id
await db.users.update({ where: { id: session.userId }, data: parsed.data });
}actions/updateProfile.ts
React Server Components — Source Code Leakage
When React Server Components serialize data to pass to the client, the serialization includes the function arguments and return values. In late 2025, CVE-2025-55183 demonstrated that malformed requests could cause Server Functions to return their own source code including any hardcoded secrets, internal URLs, or helper functions inlined by the bundler.
The fix: never put secrets inside Server Actions or components. Secrets belong in environment variables that are never sent to the client:
// Hardcoded inside a component or action
const client = new SomeClient({ apiKey: "sk-prod-abc123" });
// From environment, server-only
const client = new SomeClient({ apiKey: process.env.SECRET });
In Next.js, prefix variables with NEXT_PUBLIC_ only if they genuinely need to be on the client. Anything without that prefix stays server-side. Use the server-only package to enforce this:
import "server-only"; // throws at build time if imported on clientlib/db.ts
Content Security Policy — Your Last Line of Defense
CSP is a response header that tells the browser which sources are allowed to load scripts, styles, images, and other resources. If an attacker injects a script tag, CSP can still block it from running.
Most apps don’t have one. Here’s a working starting point for Next.js:
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' 'nonce-{nonce}'", // use nonces, not unsafe-inline
"style-src 'self' 'unsafe-inline'", // relax for CSS-in-JS if needed
"img-src 'self' data: https:",
"connect-src 'self' https://api.yourdomain.com",
"frame-ancestors 'none'", // blocks clickjacking
].join("; ")
);
return response;
}middleware.ts
A strict CSP with nonces is more work to set up than unsafe-inline but it actually blocks script injection. unsafe-inline defeats the purpose if you allow all inline scripts, XSS works fine.
Supply Chain Attacks — The Problem You’re Probably Not Thinking About
Modern frontend projects pull in hundreds of packages and you don’t audit them all. That’s the attack surface.
The event-stream incident (2018) was the first widely-noticed case where a malicious maintainer shipped backdoored code to millions of projects via a dependency. It wasn’t the last. In 2025 and 2026 the pattern keeps repeating, typically through:
- Package maintainer account takeover
- Typosquatting (
react-domvsreact-dоmusing a Cyrillicо) - Dependency confusion (private packages resolved from public registry)
A few things reduce the risk:
Lock your lockfile.
package-lock.json or pnpm-lock.yaml pins exact versions and don’t run npm install in CI without --frozen-lockfile.
Enable Subresource Integrity for CDN scripts.
If you’re loading anything from a CDN:
<script
src="https://cdn.example.com/lib.min.js"
integrity="sha384-abc123..."
crossorigin="anonymous"
></script>
If the CDN serves a modified file, the browser refuses to run it.
Run npm audit in CI.
Not perfect, it misses unknown vulnerabilities but it catches the known ones:
- name: Audit dependencies
run: npm audit --audit-level=high.github/workflows/security.yml
Prefer fewer dependencies.
Every package you don’t install is a package that can’t be compromised.
Security Headers Checklist
These are the headers worth setting. Most are one-liners:
const securityHeaders = [
["X-Content-Type-Options", "nosniff"], // blocks MIME sniffing
["X-Frame-Options", "DENY"], // legacy clickjacking protection
["Referrer-Policy", "strict-origin-when-cross-origin"],
["Permissions-Policy", "camera=(), microphone=(), geolocation=()"],
["Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload"],
];middleware.ts
HSTS (Strict-Transport-Security) tells browsers to only connect over HTTPS, even if the user types http://. The preload flag gets you into browser preload lists, which hardcodes this for first-time visitors.
Quick-Check: The Questions Worth Asking Your Codebase
Before shipping, run through these. If the answer to any of them is “I’m not sure,” that’s the one to check first:
- Is
dangerouslySetInnerHTMLused anywhere? Is it sanitized with DOMPurify? - Does every Server Action validate the session independently?
- Does every Server Action validate and type it’s inputs with a schema?
- Are user-supplied IDs used for resource lookups, or session IDs?
- Is CSP configured?
- Is npm audit running in CI?
- Are there any hardcoded secrets in Server Actions or components?
- Are cookies set with httpOnly, Secure, and SameSite?
FAQ
Is React safe against XSS by default?
Mostly yes, for JSX rendered content. React escapes HTML entities automatically. The exceptions aredangerouslySetInnerHTML, direct DOM manipulation via useRef and libraries that inject raw HTML. Those need explicit sanitization.
Do Server Actions need CSRF protection?
Next.js Server Actions check theOrigin header automatically. Traditional API Routes don't. If you're mixing both, add origin checks to your Route Handlers for any state-mutating endpoints, and set SameSite=Lax on session cookies.
What's the fastest way to check if my Next.js is vulnerable?
Check your version inpackage.json. If it's below 15.2.3 (15.x branch) or 14.2.25 (14.x branch), update. For CVE-2025-29927 specifically, you can test locally by sending a request with the x-middleware-subrequest header to a protected route and seeing if you get redirected.
Conclusion
The model has changed and frontend is no longer a thin layer on top of an API — it owns session management, data access, and business logic. That means it needs to be secured like a backend.
Most of it isn’t complicated: validate inputs, authenticate in every handler, keep dependencies updated, set the headers, use httpOnly cookies and don’t put secrets in components.
These checks aren’t part of most teams’ default workflow, so they get skipped under deadline pressure and stay skipped. Getting CVE-2025-29927 was embarrassing for Next.js. Getting hacked through middleware you forgot to audit is worse. Stay safe.