AWS Builder Center
Your Frontend App Isn't as Secure as You Think

Your Frontend App Isn't as Secure as You Think

Your React app can render, deploy, and pass review while still shipping real security holes. Frontend security lives in layers, not a single setting. This post walks the four an attacker actually meets: XSS in your own code, server-side authorization, secrets that leak into the public bundle, and your npm dependencies. Plus AWS WAF as an easy outer layer.

Senior Developer Advocate
I built a small React app that looks completely fine. It renders, it deploys, nothing throws. Then I typed a status update into it and a JavaScript alert popped up on the screen. Something I typed into a text field ran as code, and if I can do that, so can anyone.
That is cross-site scripting, and it is one of a handful of mistakes that quietly ship with apps that otherwise work. The point of this post is to show you that frontend security is not one setting you flip on. It lives in layers.
Security people call this defense in depth. The idea is that you don't put everything on one wall and hope it holds. You stack a few of them, so getting past one doesn't hand someone the whole app. We are going to walk four layers, in the order an attacker actually meets them:
  • Your own code, where cross-site scripting lives
  • Authorization, which decides who is allowed to do what
  • Secrets, the API keys and config you didn't mean to ship to the browser
  • Your dependencies, all the npm packages you never wrote
Then one more layer out front that is an easy win. Let's start where the real attack lands.

Prerequisites

You can read this straight through, but if you want to try the fixes on your own app, it helps to have:
  • A React single-page app (the examples use Vite )
  • An Amazon Cognito  user pool and an API the app calls with a token, if you want to follow the authorization section
  • Node.js 20 or later and npm
  • An AWS account and the AWS CLI  v2 for the WAF section
The code snippets stand on their own, so you can also just take the ideas back to whatever you are building.

Layer 1: Your own code

Cross-site scripting is when someone gets the browser to run their JavaScript inside your page.
Here is the reassuring part. React protects you from this by default. Anything you render the normal way, inside curly braces, React escapes for you. So a status that is literally the string <script>alert(1)</script> shows up as text on the page. It sits there, harmless, exactly as typed.
1
2
3
4
// Safe. React escapes this, so a <script> tag renders as plain text.
function Status({ text }) {
return <p>{text}</p>;
}
The only reason my demo app was vulnerable is that the code reached for dangerouslySetInnerHTML.
1
2
3
4
// Vulnerable. The raw string is written into the DOM with no escaping.
function Status({ text }) {
return <p dangerouslySetInnerHTML={{ __html: text }} />;
}
That prop takes a raw string and writes it into the page as-is, no escaping. The name is basically a warning label, and React made it deliberately to type so you notice. This is not only a React thing either. Plain JavaScript has innerHTML, and older codebases are full of it. Any time raw input gets written to the page as HTML, you have to be careful.
The best move is to not use dangerouslySetInnerHTML at all. A status is just text, so render it the normal way with curly braces and let React do its job. You only reach for that prop when you genuinely need to render HTML, like a formatted comment or content from a CMS. In that case, run the string through a sanitizer such as DOMPurify  first, so the dangerous parts are stripped before anything reaches the page.
1
2
3
4
5
6
7
import DOMPurify from 'dompurify';

// Only when you truly need to render HTML. Sanitize first.
function RichComment({ html }) {
const clean = DOMPurify.sanitize(html);
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
Underneath the fix is the rule that carries through this whole post: never trust input from the user. Escape it or sanitize it before it ever reaches the page. Output encoding handles the values you render normally; sanitization handles the HTML you deliberately inject.

Layer 2: Authorization

Layer two is "auth," which is really two words that get squashed together and mean different things.
Authentication is who you are. Authorization is what you are allowed to do.
On AWS, the usual answer to "who are you" is Amazon Cognito. Your app signs the user in, and Cognito hands back a token called a JWT, short for JSON Web Token. Think of it as a signed badge the server can check. The app sends that badge on every call to your API. In front of the API sits an authorizer, and without a valid token the request stops at the door with a 401 and your code never runs (API Gateway Cognito authorizer ). That is authentication, the door check.
Here is an example. You have a delete button, and you only show it to admins:
1
{user.role === 'admin' && <DeleteButton />}
That feels secure. It is not. The frontend is just JavaScript that ran in the browser, and the endpoint behind that button is sitting right there in the shipped code for anyone to read. Hiding the button did nothing to protect DELETE /api/users/123. That is security through obscurity, hoping nobody goes looking for the thing you never actually locked.
The fix is that the server has to run the check on every request, not just the UI. Here is that same endpoint actually verifying the caller's group before it does anything:
1
2
3
4
5
6
7
8
9
10
// Lambda behind API Gateway. The JWT claims arrive already verified
// by the Cognito authorizer; the handler still checks authorization.
export const handler = async (event) => {
const groups = event.requestContext.authorizer.claims['cognito:groups'] ?? '';
if (!groups.split(',').includes('admin')) {
return { statusCode: 403, body: 'Forbidden' };
}
// ...perform the delete
return { statusCode: 200, body: 'Deleted' };
};
On AWS you carry this with Cognito groups in the token, a Lambda authorizer, or IAM for anything that touches AWS directly. And give each of those only the access it actually needs, nothing more. That is the principle of least privilege (IAM best practices ). The payoff is blast radius: if one of those roles ever gets compromised, the damage is limited to the small part it could touch, instead of your whole account. Broken access control is the top item on the OWASP Top 10 for a reason (OWASP ), and hiding the UI does not move you down the list. The API is the security control.

Layer 3: Secrets

Your frontend is public. All of it. Minified code is not hidden code.
It is easy to look at a key tucked into a config file and feel like it is tucked away. It isn't. Build for production and you can pull it straight out of the shipped bundle:
1
2
npm run build
grep -ri "api_key\|secret\|AKIA" dist/
A hardcoded string is the obvious case. In a Vite app the more common one is the VITE_ prefix. Vite only bundles environment variables you mark with that prefix, which is meant as a guardrail (Vite env variables ). The trap is that the guardrail catches you the moment you drop a real secret into your .env file and prefix it with VITE_ to make it reachable:
1
2
# This ships to every visitor. VITE_ means "put this in the browser bundle."
VITE_STRIPE_SECRET_KEY=sk_live_... # do not do this
Hardcoded or prefixed, if a value reaches the browser, treat it as public. Real secrets live on the server, behind an API call the user is authorized to make.

Layer 4: Your dependencies

Your app is a lot of packages written by other people. One npm install pulls in hundreds of packages, and any one of them runs with the exact same power as the code you wrote. So a compromised package is one of the realistic ways a frontend app gets compromised.
npm audit is a start, but it is the floor, not the ceiling. A few things that actually help:
  • Commit your lockfile and install with npm ci, so builds are reproducible and you aren't silently pulling new transitive versions.
  • Turn on Dependabot  (or Renovate) so update PRs and vulnerability alerts come to you.
  • Prefer packages that publish provenance , a signed record of how they were built.
  • Watch for typosquats, a package named one letter off from the real one.
The same care applies to your pipeline. A leaked npm token or a compromised GitHub Action ships straight to your users, so pin your actions to a commit SHA and keep tokens narrow. And do not rush to install a version the second it drops. Pin exact versions and give new releases a few days before you upgrade.

AI coding agent skills are dependencies too

If you use an AI coding agent, the skills you add to it can feel like harmless little helpers. A lot of them are. But a skill is really instructions plus scripts that run with your agent's full power: your files, your tokens, your other skills. Usually nothing reviews them before they run, and a malicious skill looks just like a helpful one sitting in your file tree.
So treat every skill the way you treat a dependency. Read the skill, and any scripts it pulls in, before you install it, the same way you would check an npm package. If you want a trustworthy one to start with, the Agent Toolkit for AWS  is a good example of a skill from a source you can actually verify.

One layer out front: AWS WAF

There is one more layer I want to add. A web application firewall. AWS WAF  sits at the edge and blocks a lot of the bad traffic before it ever reaches your app. You can turn on managed rule groups without writing a single rule, including a common rule set that covers a lot of OWASP-style ground (AWS Managed Rules ).
Add a WAF to anything you ship. It is a strong outer layer that keeps a ton of automated junk away from your code. Think of it as sitting alongside the four layers we just built, not replacing them. The WAF handles the obvious stuff at the edge; your code handles what is specific to your app. A WAF is also bypassable and not a substitute for the fixes above, so treat it as the outer ring and not a force field.

The checklist

Run your app through this:
  • No dangerouslySetInnerHTML on user input; if you must render HTML, sanitize it with DOMPurify first
  • API routes sit behind a Cognito authorizer and reject requests with no token
  • Every permission check runs on the server (Cognito groups, JWT claims, or a Lambda authorizer), not just hidden in the UI
  • Each role has only the access it needs, following least privilege
  • No secrets in the frontend bundle, VITE_-prefixed or otherwise, and the SPA's Cognito app client has no client secret
  • Lockfile committed, npm ci in CI, Dependabot on, and npm audit clean
  • Actions pinned to a SHA, tokens scoped narrow, and new package versions given a few days before upgrading
  • Every AI agent skill read and vetted like a dependency before install
  • A WAF in front with a managed common rule set
What do you think? Leave a comment below!
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article