CSRF, XSS, Headers, Secrets, and Secure Uploads
Security in a Next.js application is not a single middleware file or a package added at the end. The App Router mixes server rendering, browser hydration, route handlers, cookies, server actions, static assets, and deployment configuration. That means a bug can appear at a boundary: a browser automatically sends a cookie to the wrong request, untrusted HTML reaches the DOM, a response misses a protective header, a secret becomes part of a client bundle, or an uploaded file is trusted too early.
The outcome of this lesson is concrete: you should be able to describe how CSRF, XSS, security headers, secrets, and uploads fail in Next.js, then implement layered defenses that fit the framework. These topics belong in the authentication section because authentication only says who the user is. Security engineering decides when the browser may act for that user, what input may become UI, what the browser is allowed to load, where privileged values live, and what files are allowed to enter your system.
How The Attacks Work
CSRF, cross-site request forgery, abuses ambient authority. If your application stores a session in a cookie, the browser may attach that cookie to requests even when the request was initiated from another site. A malicious page cannot normally read your response, but it may still try to submit a state-changing request. SameSite cookies reduce this risk, but you should still require intentional proof for sensitive mutations: verify method, origin, content type, authentication, authorization, and a nonce or CSRF token for browser-submitted forms.
XSS, cross-site scripting, happens when attacker-controlled text becomes executable JavaScript or dangerous markup. React escapes string values by default, so {name} renders text rather than HTML. The dangerous paths are explicit escape hatches and browser APIs: dangerouslySetInnerHTML, rich-text rendering, Markdown converted to HTML, script-capable SVGs, unsanitized URLs, inline event attributes, and third-party widgets. In Next.js, XSS can cross the server-client boundary when server data is serialized into client components or embedded in script tags.
Security headers instruct the browser to enforce rules even if application code misses something. A Content Security Policy limits where scripts, styles, frames, images, fonts, and connections can come from. X-Frame-Options or CSP frame-ancestors limits clickjacking. Referrer-Policy reduces URL leakage. X-Content-Type-Options: nosniff makes MIME mistakes less exploitable. These headers do not replace validation, but they shrink the blast radius.
Secrets are values that grant access: database URLs, signing keys, API tokens, webhook secrets, and encryption keys. In Next.js, variables without a public prefix are intended for server-side code, while public-prefixed variables are bundled for the browser. The security rule is stronger than naming: never import server-only modules into client components, never log secrets, and never depend on a public value for authorization.
Secure uploads protect the application before, during, and after accepting bytes. A file name, extension, MIME type, and client-provided size are all untrusted. Validate size limits at the edge or route handler, inspect content where needed, store files outside executable paths, generate server-owned object keys, and scan or quarantine files before making them public.
API And Configuration Anatomy
In the App Router, security controls usually live in five places. middleware.ts can add headers to many responses and reject requests early. Route handlers such as app/api/upload/route.ts handle HTTP methods and request bodies. Server actions handle form submissions but still need authorization and input validation. Cookie settings define browser behavior with httpOnly, secure, sameSite, path, and expiry. Environment variables feed server code at runtime or build time depending on hosting and bundling.
A practical security review asks these questions for each mutation or upload. Which credential identifies the user? Which operation changes state? Can a browser send this request automatically? Which field is attacker-controlled? Which value becomes HTML, a URL, a file path, a storage key, or a database query? Which response tells the browser what it may execute?
Example 1: CSRF Token For A Form
This example implements a double-submit style token. The server creates an unpredictable token, stores it in an HTTP-only cookie, places the same token in the form, and requires both values to match on POST. In a real app, bind the token to a session and rotate it after sensitive actions.
import crypto from "node:crypto";
export function createCsrfToken() {
return crypto.randomBytes(32).toString("base64url");
}
export function verifyCsrfToken(cookieToken, formToken) {
if (!cookieToken || !formToken) return false;
const a = Buffer.from(cookieToken);
const b = Buffer.from(formToken);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const token = createCsrfToken();
console.log(verifyCsrfToken(token, token));
console.log(verifyCsrfToken(token, "attacker"));
The deterministic behavior is that the matching token prints true and the forged token prints false. The comparison uses timingSafeEqual after checking length, avoiding a string comparison that may leak small timing differences. In Next.js, generate the token in a server component or route handler, set it with cookies(), and verify it before the mutation. Also reject unexpected Origin headers for authenticated POST, PATCH, PUT, and DELETE requests.
Example 2: XSS-Safe Rendering
The safest default is to render user content as text. When rich text is a product requirement, sanitize on the server and allow only the tags and attributes your UI actually supports. Do not sanitize once and assume all future contexts are safe; text used in HTML, URLs, CSS, and scripts has different escaping rules.
export function Comment({ body }) {
return <article>{body}</article>;
}
export function UnsafeComment({ trustedHtml }) {
return <article dangerouslySetInnerHTML={{ __html: trustedHtml }} />;
}
If body is <img src=x onerror=alert(1)>, the first component displays those characters as text. The second component asks React to insert HTML directly, so it is only acceptable when trustedHtml has been produced by a reviewed sanitizer and policy. A common Next.js mistake is passing CMS HTML straight through a server component into a client component because it came from an internal database. Databases store attacker-controlled content too.
Example 3: Security Headers In Middleware
Headers should be boring, explicit, and tested. Start with a restrictive policy, then add sources required by your application. Avoid broad wildcards because they make later XSS bugs easier to exploit.
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const csp = [
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'"
].join("; ");
export function middleware(_request: NextRequest) {
const response = NextResponse.next();
response.headers.set("Content-Security-Policy", csp);
response.headers.set("X-Content-Type-Options", "nosniff");
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
return response;
}
The expected browser behavior is that same-origin scripts still run, but an injected external script from an unlisted host is blocked and reported in the console. The style-src line allows inline styles because many React styling setups need them; that is a trade-off, not a universal recommendation. Mature applications often move toward nonces or hashes for stricter script and style policies.
Example 4: Validated Upload Route
This upload handler rejects large payloads, validates the file object, checks a small allowlist, and generates a server-owned storage name. It avoids trusting the original file name as a path.
import { NextRequest, NextResponse } from "next/server";
const allowedTypes = new Set(["image/png", "image/jpeg", "application/pdf"]);
const maxBytes = 5 * 1024 * 1024;
export async function POST(request: NextRequest) {
const form = await request.formData();
const file = form.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "file is required" }, { status: 400 });
}
if (file.size > maxBytes || !allowedTypes.has(file.type)) {
return NextResponse.json({ error: "unsupported upload" }, { status: 415 });
}
const key = `${crypto.randomUUID()}-${file.name.replace(/[^a-zA-Z0-9_.-]/g, "_")}`;
return NextResponse.json({ key, bytes: file.size, type: file.type }, { status: 201 });
}
A valid PNG under five megabytes returns status 201 with a generated key. A missing file returns 400. A large file or disallowed MIME type returns 415. For high-risk systems, add content sniffing, antivirus scanning, image transcoding, object-store private ACLs, and a separate publish step after scanning succeeds.
Design Choices And Trade-Offs
CSRF tokens add state and form wiring, but they give explicit proof that the rendered page and submitted mutation belong together. SameSite cookies are valuable but not a complete design because browser behavior, redirects, subdomains, and cross-site flows can be subtle. Header policies reduce exploitability, but a policy that is too strict can break analytics, payment widgets, fonts, or image CDNs. The right process is report-only testing first, then enforcement with narrow exceptions.
For XSS, plain text rendering is simpler and safer than rich HTML. If rich HTML is required, define a content model instead of allowing arbitrary markup. For uploads, direct-to-object-storage uploads improve performance and avoid tying up serverless execution, but the server must still issue scoped upload URLs, enforce ownership, and verify the completed object before exposing it. For secrets, runtime environment variables ease rotation, while build-time public variables are appropriate only for values that are safe for every visitor to see.
Failure Modes And Troubleshooting
Symptom: users are signed in, but a POST from your form returns 403. Cause: the CSRF cookie path, domain, or SameSite setting prevents the token cookie from being sent. Diagnose: inspect the request in DevTools, compare cookie attributes with the form route, and log only token presence, never token values. Correct: set the cookie on the correct path, use HTTPS with secure cookies, and regenerate the form token after login.
Symptom: a production page shows CSP errors and a checkout widget stops loading. Cause: the enforced policy omits the widget script, frame, or connection endpoint. Diagnose: reproduce with DevTools open, collect violated directives, and compare them with vendor documentation. Correct: add the narrow vendor origins needed for that route, preferably route-specific, and avoid relaxing default-src globally.
Symptom: an environment variable is visible in browser source maps or network payloads. Cause: the value was public-prefixed, imported into a client component, or serialized into props. Diagnose: search for the variable name, inspect the client bundle, and review imports crossing a use client boundary. Correct: move privileged work to a server-only module or route handler, rotate the leaked secret, and invalidate dependent credentials.
Symptom: uploaded files sometimes download as executable HTML. Cause: the storage layer serves user content with a dangerous content type or from the same origin as the app. Diagnose: inspect response headers from the stored object and test a renamed HTML file. Correct: store uploads on a separate asset host, force safe content disposition for untrusted files, and set nosniff.
Hands-On Lab
Prerequisites: a local Next.js App Router project, Node.js, a browser with DevTools, and a test account. Work on a branch because you will intentionally trigger blocked requests.
- Create
middleware.tsand add the security headers from the middleware example. - Create a profile update form that receives a CSRF token from the server and posts it back in a hidden field.
- Set an HTTP-only CSRF cookie when rendering the form. Submit the form once with the correct token and once after changing the hidden field in DevTools.
- Add an upload route based on the example. Try a small PNG, a missing file, and a file larger than your configured limit.
- Add a comment component that renders text normally. Test the string
<img src=x onerror=alert(1)>and confirm it appears as text. - Move any API key used by these routes into an unprefixed environment variable and confirm it is accessed only from server files.
Verification: the valid form submission succeeds, the forged token fails with 403, CSP headers appear in every document response, the XSS payload does not execute, invalid uploads are rejected, and no secret value appears in the client bundle or browser network payloads.
Cleanup: remove test uploads from storage, delete temporary test accounts, rotate any credential that was printed during debugging, and keep the header and validation tests in the project.
Assessment Exercises
- A settings page uses a cookie session and a server action. Design the checks needed before changing a user’s email address, including CSRF and authorization checks.
- Your CSP blocks an inline script added by a tag manager. Explain two safer options than adding broad
'unsafe-inline'to every route. - A product manager wants users to upload SVG avatars. List the risks and propose a safer implementation path.
- Given a component that uses
dangerouslySetInnerHTMLfor CMS content, define the sanitizer policy and tests you would require before release. - An API key accidentally shipped to the browser. Describe the immediate containment steps and the code changes that prevent recurrence.
Summary
Secure Next.js applications by treating browser boundaries as hostile and server boundaries as deliberate. CSRF defenses prove that a state-changing request came from your own rendered flow. XSS defenses keep untrusted content from becoming code. Headers make the browser enforce additional limits. Secret handling keeps privileged values on the server. Secure upload design validates bytes, names, types, storage, and publication. None of these controls is perfect alone; together they make authentication meaningful after the user signs in.
