Front-End Security: What Actually Protects Your Users
Most front-end security advice is really server advice wearing a different hat. These practices live in the browser, where your code actually runs.
Much of what gets published as front-end security isn't front-end security. Install a TLS certificate, put a WAF in front of your origin, patch your server, take backups, run an audit. All sound advice, none of it front-end, and none of it something a front-end engineer typically controls.
The front end has its own threat model, and it is a genuinely awkward one. Your code runs on a machine you do not own, in a runtime you cannot inspect, alongside whatever else the user has installed, and every line of it is readable by anyone who cares to look. You cannot enforce anything there. What you can do is limit what an attacker gains when something goes wrong, and reduce the number of ways things can go wrong in the first place.
Almost every browser-side attack has the same objective: run attacker-controlled JavaScript inside your origin. Once that happens, the attacker has whatever your JavaScript has. Session tokens, the DOM, network access as the user, the lot. So the practices below are ordered by how much they reduce the chance of that, or how much they contain the damage afterwards.
Cross-site scripting, and why it has not gone away
XSS remains the centre of front-end security, and it persists despite twenty years of attention because frameworks solved the easy half.
React, Vue, Svelte and Angular all escape interpolated values by default, which eliminated the classic case of concatenating user input into a template. What they did not eliminate is the escape hatch, and every framework has one: dangerouslySetInnerHTML, v-html, {@html}, bypassSecurityTrustHtml. Each is a deliberate opt-out, and each shows up in production wherever someone needed to render rich text and reached for the quickest route.
The other surviving category is DOM-based XSS, where no server is involved. Something reads from location.hash, document.referrer or postMessage, and writes it into a sink like innerHTML, document.write, or a dynamically constructed <script> src. The server never sees the payload, so server-side filtering never fires, and it stays invisible to much scanning.
The traditional fix is to sanitise with a library, usually DOMPurify, which works but has a structural weakness: the library parses the HTML, cleans it, serialises it back to a string, and then the browser parses it again. That double parse is exactly where mutation-based bypasses live, and there have been many over the years.
The browser now offers a better route. setHTML() inserts HTML through a sanitiser built into the parser, with no serialise-and-reparse round trip:
element.setHTML(untrustedString);
Its baseline protections cannot be configured away. Script elements, event handler attributes and javascript: URLs are removed regardless of what you allow, and the default configuration is stricter still, stripping much of what you would consider ordinary markup. For a comments field or a rich text editor, you can widen it deliberately:
const sanitizer = new Sanitizer({ elements: ["p", "b", "i", "em", "ul", "li", "a"] });
element.setHTML(untrustedString, { sanitizer });
Support is real but uneven as of 2026. setHTMLUnsafe() reached Baseline in September 2025 and is available everywhere, while the safe setHTML() with custom configuration arrived in Firefox 148 in February 2026 and is at varying stages elsewhere. Feature-detect and fall back to DOMPurify where it is missing, which is a handful of lines and lets you drop the dependency later.
Above both of those sits Trusted Types, which is the part worth understanding properly. Sanitising correctly at each call site depends on every developer remembering to do it, forever, including in third-party code you did not write. Trusted Types removes that dependence by making the unsafe sinks refuse plain strings entirely:
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default
With that header, an assignment to innerHTML throws unless the value came from an explicit policy. It converts "we hope nobody introduces XSS" into "the DOM will not accept unsanitised input", which is a categorically stronger position. Roll it out in report-only mode first, because it will surface violations in dependencies you had no idea were writing to the DOM.
Content Security Policy, done properly
Most CSP headers in the wild do very little, because they are built as allowlists of domains. Allowlist CSPs fail routinely: they end up including a CDN that also hosts an endpoint capable of executing arbitrary code, or a JSONP interface, or they accumulate 'unsafe-inline' because an inline script somewhere needed it, at which point the policy provides no XSS protection at all.
The approach that works is nonce-based:
Content-Security-Policy:
script-src 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline';
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
The server generates a fresh random nonce per response and stamps it on legitimate script tags. 'strict-dynamic' lets a trusted script load further scripts without you enumerating every domain, which is what makes this maintainable. The trailing https: and 'unsafe-inline' are fallbacks for older browsers that ignore 'strict-dynamic', and modern browsers ignore them.
Three directives deserve individual attention because they are commonly omitted. object-src 'none' closes off plugin-based execution. base-uri 'none' prevents an injected <base> tag from redirecting every relative script URL on the page to an attacker's host, which is a bypass that defeats otherwise sound policies. And frame-ancestors 'none' is the modern replacement for X-Frame-Options, handling clickjacking with more precision.
Deploy with Content-Security-Policy-Report-Only and a reporting endpoint first. A CSP rolled out cold will break something; you will get paged, and the fastest fix under pressure is always to weaken the policy. Collect violations for a couple of weeks, then enforce.
Google's CSP Evaluator will tell you honestly whether your policy actually resists bypass, and it is worth running against whatever you have now.
The supply chain is now the main event
This is the biggest change in front-end security since the original version of this article, and it doesn't appear in most guides at all.
The size of the problem is worth stating plainly. In September 2025, a phishing attack against a single maintainer compromised eighteen packages, including chalk and debug, together accounting for around 2.6 billion weekly downloads. Days later, the Shai-Hulud worm became the first self-replicating supply chain malware on npm, stealing credentials and using them to publish poisoned versions of every package its victim could publish, spreading to over 500 packages. Successor campaigns have continued through 2026.
The relevant point for front-end work is that a compromised dependency is not a partial breach. Anything in your bundle runs with your origin's full privileges, so a malicious package has the same access your own code does. No sandbox to fall back on.
What actually helps:
Reduce the count. Every dependency is a maintainer account that can be phished. The reflex to install a package for something the platform now does natively is where most of the surface comes from.
Commit lockfiles and use npm ci in CI, so builds are reproducible and a caret range cannot silently pull a compromised patch release the moment it is published.
Disable lifecycle scripts by default with npm config set ignore-scripts true. Both major 2025 campaigns executed on install, and the second wave moved from postinstall to preinstall to widen the window. Most packages do not need scripts, and the ones that do can be allowed explicitly.
Delay adoption. Configure a minimum release age before a version is eligible for install, since malicious releases are usually caught within hours. The chalk payload was live for roughly two hours. A seven-day quarantine would have caught every major incident so far.
Use Subresource Integrity for anything loaded from a CDN, which pins the exact bytes you tested against:
<script src="https://cdn.example.com/lib.js"
integrity="sha384-..." crossorigin="anonymous"></script>
SRI is not a general fix, since it cannot be applied to a script that legitimately changes, which is precisely the case for analytics and tag managers. That limitation also tells you how much trust those scripts require.
Third-party scripts are full trust
Every third-party script on your page can read your DOM, your cookies that are not HttpOnly, and can make requests as the user. Analytics, chat widgets, A/B testing, advertising, tag managers. There is no partial permission model.
A tag manager is worth singling out, because it converts a marketing tool into arbitrary code execution on your production site, typically governed by weaker access control than your git repository. The 2024 Polyfill.io incident, where a widely trusted script was sold and turned malicious across hundreds of thousands of sites, is the canonical demonstration.
Practical mitigations: audit what is actually loaded rather than what you believe is loaded, run third-party content in a sandboxed iframe where the functionality permits it, and use CSP to constrain where those scripts can send data, since exfiltration needs an outbound connection and connect-src governs it.
Sessions and token storage
Storing session tokens in localStorage is common and is a mistake for a specific reason: any JavaScript on the page can read it, so a single XSS becomes a stolen session that survives long after the page closes.
The better pattern is a cookie the browser will not hand to script:
Set-Cookie: __Host-session=...; HttpOnly; Secure; SameSite=Lax; Path=/
HttpOnly puts it out of reach of JavaScript entirely. SameSite=Lax handles most CSRF without extra machinery. The __Host- prefix is the underused part: it forces the browser to reject the cookie unless it is secure, host-only and path-root, which prevents a compromised subdomain from setting a cookie that your main site will accept.
If your architecture genuinely requires tokens in JavaScript, keep them short-lived and in memory rather than in any persistent store, and accept that XSS means compromise. That is a trade-off to make consciously, not by default.
The headers worth setting
Beyond CSP, a short list carries most of the remaining value:
Strict-Transport-Security with a long max-age forces HTTPS at the browser level and removes the initial plaintext request an attacker could intercept.
X-Content-Type-Options: nosniff stops the browser second-guessing your content types, which is how an uploaded file gets executed as script.
Referrer-Policy: strict-origin-when-cross-origin prevents leaking full URLs, including any identifiers in paths or queries, to external sites.
Permissions-Policy disables capabilities you don't use, so injected or third-party code can't request the camera, microphone, or geolocation.
Cross-Origin-Opener-Policy: same-origin isolates your browsing context from windows that opened it, closing off a category of cross-window attacks.
None of these takes long. securityheaders.com will show you what you are currently sending.
What the front end cannot do
Worth stating explicitly, because confusion here produces real vulnerabilities.
Client-side validation is a user experience feature. It tells someone their email is malformed before they submit. It is not a security control, because the client is under the attacker's control and requests can be made without your interface. Every check must exist on the server, no matter how thorough the browser-side version is.
Nothing in your bundle is secret. Not an API key in an environment variable that gets inlined at build time, not a value obfuscated by minification. If a key is in code the browser downloads, it is public, and the fix is architectural: proxy through your own backend and keep the credential there.
Authorisation is not a front-end concern. Hiding an admin button prevents confusion, not access. If the endpoint does not check permissions, hiding the button changes nothing.
And CORS is frequently misread. It does not protect your server. It stops other origins' JavaScript from reading your responses in a browser, a narrow guarantee in a specific context. A permissive Access-Control-Allow-Origin: * on a public API is fine; the same header on an authenticated endpoint is a problem. Either way, CORS is not access control.
Verifying rather than assuming
Run CSP Evaluator against your policy, check your headers with securityheaders.com, and audit dependencies continuously rather than quarterly. Dependabot or Renovate for updates, and tooling like Socket for behavioural analysis, which catches a package that suddenly starts making network requests in a way a CVE database will not.
Then hold it in CI. A build that fails on a known-vulnerable dependency or a weakened CSP is worth more than any amount of documented process, for the same reason a test suite is worth more than a code review checklist.
The short version
Assume XSS is your primary risk and everything else is secondary. Deploy a nonce-based CSP with object-src, base-uri and frame-ancestors set, and move toward Trusted Types so unsafe sinks stop accepting strings altogether. Treat every dependency as code you choose to run with full privileges, and reduce, pin, and delay accordingly. Keep session tokens in HttpOnly cookies. Set the five headers above.
That is most of the available protection, and it takes less time than the certificate renewal everyone remembers to do instead.