A few months back I was doing a code review for a friend's project and noticed they were storing JWTs in localStorage. I almost said nothing because honestly, I had done the same thing in like three projects before someone pointed it out to me. That's kind of the problem with frontend security. Nobody really teaches it properly, so we all just copy patterns from tutorials that were written to be quick, not safe.
So here's what I've picked up, mostly from messing things up myself or catching bugs before they became actual incidents.
XSS is more common than you think
Cross-Site Scripting is when attacker-controlled JavaScript ends up running in your user's browser. Sounds dramatic. It's also surprisingly easy to introduce by accident.
The most common way I've seen it happen in React is through dangerouslySetInnerHTML. Someone needs to render rich text from a CMS or a user comment, they google how to do it, find this prop, use it, and move on.
function Comment({ userInput }) {
return <div dangerouslySetInnerHTML={{ __html: userInput }} />;
}
If userInput is coming from anything a user typed or any external source, this is a problem. An attacker can craft input that includes script tags and now that runs in every other user's browser that loads the comment.
The fix is pretty straightforward. React escapes content by default when you render it normally, so just do that when you can:
function Comment({ userInput }) {
return <div>{userInput}</div>;
}
And when you genuinely need to render HTML, run it through DOMPurify first. It strips out anything dangerous before it hits the DOM.
import DOMPurify from 'dompurify';
function Comment({ userInput }) {
const clean = DOMPurify.sanitize(userInput);
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
Not perfect, but significantly better than nothing.
CSRF is the one people forget about entirely
I genuinely forgot CSRF was a thing for a while. It doesn't come up much in React tutorials because the React part isn't really where the vulnerability lives.
The short version: if your app uses cookies for auth, an attacker can get a logged-in user to make requests to your server from a completely different site. The browser sends the cookies automatically, your server sees a valid session, and whatever that request was supposed to do just... happens.
The standard defenses are CSRF tokens (a secret value the attacker can't read from their site) and setting SameSite=Strict or SameSite=Lax on your cookies. The SameSite attribute alone cuts off most of these attacks without much extra work.
Set-Cookie: session=abc123; SameSite=Strict; Secure; HttpOnly
If you're using a backend framework, there's usually middleware that handles CSRF token generation and validation. Worth checking if it's actually enabled rather than assuming.
localStorage is fine until it isn't
I know the feeling. Tutorial says put the JWT in localStorage, you put it in localStorage, app works, you ship it. And most of the time nothing bad happens.
The issue is that localStorage is readable by any JavaScript running on the page. So if you ever end up with an XSS vulnerability (a bad npm package, a forgotten dangerouslySetInnerHTML, whatever) the attacker's script can just read your token straight out of storage and send it somewhere.
localStorage.setItem('token', data.token);
// readable by literally any JS on this page
HttpOnly cookies solve this because JavaScript can't read them at all. The browser sends them with requests automatically, but no script can access the value. The tradeoff is that your backend needs to set the cookie for you, which adds a bit of coordination. But if you're already building an API, it's not that much extra work.
Security headers exist and you're probably not sending them
This took me embarrassingly long to actually implement. Security headers are HTTP response headers your server sends that tell the browser how to behave. Most apps don't send them because nobody ever runs into a direct error from not having them.
Content Security Policy is the big one. It tells the browser which sources are allowed to load scripts, styles, images, etc. A solid CSP adds a meaningful second layer against XSS because even if a script gets injected, the browser may refuse to run it.
Content-Security-Policy: default-src 'self'; script-src 'self'
There's also X-Content-Type-Options: nosniff which stops browsers from sniffing content types, and X-Frame-Options: DENY which prevents your app from being iframed on other sites (relevant for clickjacking).
If you're running an Express backend, the Helmet package sets most of these in a few lines. If you're on Vercel or Netlify, you can set them in a config file. Either way it's not a big lift once you actually sit down to do it.
Accidentally shipping secret keys
This one has bitten people I know. React apps bundle environment variables into the JavaScript that ships to the browser, which means anyone can open DevTools and read them.
In Create React App, only variables prefixed with REACT_APP_ get included in the bundle. In Vite, it's VITE_. The problem is people forget this rule and prefix something they shouldn't.
# now visible to anyone who opens the network tab
REACT_APP_STRIPE_SECRET_KEY=sk_live_abc123
Secret keys belong on the server. If your frontend needs to charge a card, it should call your own API endpoint, which then calls Stripe using the secret key from a server-side environment variable. The frontend never needs to touch the key directly.
Honestly
Most of this stuff isn't complicated once you know it exists. The gap is that nobody really lays it out together in one place during the "learn React" phase, so you end up shipping apps with several of these issues without realizing it.
I'm still finding old projects of mine where I got lazy about some of this. If you're in the same boat, pick one thing from this list and fix it this week. That's usually how I approach it rather than trying to audit everything at once.
If you've hit a security bug in production or caught one before it went out, I'd genuinely like to hear what it was in the comments.
Let's Connect!
LinkedIn: adhikareeprayush
Portfolio: prayushadhikari.com.np
Top comments (0)