March 18, 2025
Frontend Security: The Front Fell Off
As frontend developers we often thing of security as a somebody-else problem. It’s true that the bigger issues are tackled in the backend…

By Matt Burgess
13 min read
As frontend developers we often thing of security as a somebody-else problem. It's true that the bigger issues are tackled in the backend, but as frontend developer there are specific concerns that we need to be aware of.
I had an interview recently for a frontend role that went way more into security requirements than I had been fully prepared for. Though I was familiar with the security issues and mitigation I wasn't as ready with terminology and methodologies as I should have been.
It made me realise that as frontend developers we often undervalue security, and our role in ensuring it — and therefore our culpability when we don't.
But I thought it might be useful to go through some of the vectors of attack here, talk about how they actually work in the context of the modern JavaScript-centric web, and how they can be mitigated or eliminated.
Cross-Site Scripting aka XSS
The injection of a malicious script into a page, which is then executed, triggering unwanted behaviour.
The longer and more concrete example might be something like a message board or comments form, that displays user input on the page for other users. If the user's message consists of something along the lines of this, it's actually going to trigger an alert on everyone's screens.
<script>alert(‘Website hacked’)</script><script>alert(‘Website hacked’)</script>This is a mild example. Running some eval base64 encoded nonsense that connects to and executes a malicious file is more likely and much worse.
Mitigation of this isn't particularly difficult, and involves a principle that will carry through the rest of these examples: never trust user input. This means never trust what users are putting in the form — sanitise it, and validate it to prevent obvious attempts. Additionally never directly display user inserted content. This should be obvious, but many a corporate seminar has been impacted by a "guest book" that got turned into Nazi propaganda or worse.
As well as not directly displaying user input, always use the "safe" version of content display. This is always the default. In React, for example, using {messageContent} is safe, but if you use <div dangerouslySetInnerHtml={messageContent} /> that's bad. If you weren't aware that's unsafe it might be worth considering if another field is more suited to you. Vue is less explicitly fearmongering, but {{messageContent}} is safe, while <div v-html="messageContent"> very much is not.
Another mitigation for this, and it applies to quite a few issues, is CSP: Content Security Policy. We'll talk about that in too much detail soon.
To be blunt XSS is a "solved problem" in web development unless you're doing some very silly things. Most modern workflows will stop it at the door.
Cross-Site Request Forgery
Tricks a user into making a request that they didn't actually intend to make, or even know they were making. These attacks rely on the fact that there's a real user, with real authentication, and a valid cookie. When you make a request on the actual website it sends that cookie along with the request, to prove you're valid.
So you're on SuperGoodBank.com finalising a personal loan to buy two Warhammer 40K figures and you finish it and leave. Then the next day you go to PrivateerHarbour.com to download Bring It On: Fight To The Finish and you don't notice that one of the images embedded in the page goes to https://api.supergoodbank.com/api/transactions/transfer&amount=150&destination=666-237884-123&approved=true.
Your bank's API gets sent that URL, and your cookie with it. So it makes that transaction accordingly.
You may have noticed that his actually isn't a frontend risk. It's actually a backend risk, and the solution involves a backend change that the frontend has to work with as well. The best solution involves the use of a CSRF token. This token is generated once per session and included as part of the payload of any form. No valid token? No valid request.
It's important to understand that these requests and their solutions are specifically form based — application/x-form-urlencoded or multipart-form-data.
JavaScript based forms sending application/json payloads have a different set of requirements but may still be required to include a CSRF token as part of the request. Often CORS requirements block CSRF on JavaScript-based requests. And yeah, we'll go into excruciating detail about that as well.
Most of the mitigation for this is backend — using SameSite only cookies, implementing 2FA on high risk requests, etc. But as a frontend developer we should be ready to add whatever headers or fields our requests might need.
Clickjacking
Clickjacking is not dissimilar to cross site forgery, but involves wrapping the target site in an iframe and tricking them into clicking buttons on a UI they can't see, and that they think is doing something else. So for example, I think I'm clicking to confirm my search for Bring It On movies on the PrivateerHarbour website, but actually the button underneath is to "Confirm Transfer" of my vast fortune.
The action is executed because… sure. You have a valid cookie, and you did click the "Send All My Money To Scammers" button.
Real-world hacks have included things like forcing users to "like" Facebook content they never saw, or approve PayPal payments.
Mitigation of this one isn't too hard. There's a HTTP header that you can set called X-Frame-Options and if set to DENY no embedding or framing of the application will be possible. (Or use SAMEORIGIN if you have a need for internal usage.) The same applies to CSP settings: fake-ancestors 'none';. We'll go more into CSP later, as I already threatened.
This isn't that common a vector anymore, but good solutions require access to the hosting or server config. There are some css and JavaScript tricks to try and at least restore the visibility of the page if embedded, but these aren't really the solution.
SQL Injection
This is another one that's really a backend issue that we can at least try to mitigate on the frontend. Ultimately this involves inserting slabs of SQL in the hope that the backend is naive and executes it without thought.
If you ask a user their name in a field you might just directly insert into the user.name field with something like UPDATE users SET name = <userData.name> WHERE userID = 123.
If the user posts their name as "REKD"; DROP TABLE users; then the final query will be this.
UPDATE users SET name = “RKD”; DROP TABLE users; WHERE userID = 123UPDATE users SET name = “RKD”; DROP TABLE users; WHERE userID = 123The last section there won't do anything, but the first one will be a perfectly valid query that will update everyone's name. Then the second will be a completely valid query to entirely remove the users table.
This is not ideal.
Preventing the users from doing this is pretty easy from a backend point of view. Any ORM will block it. Even lower level libraries allow things like prepared statements, which will also sanitise this.
As a frontend developer you can at the very least do basic validation and sanitation to prevent obvious garbage and/or hacks being sent to your server. Limit fields to the character sets that they actually would use, for example.
CSP (this comes up a lot) can also help, by removing vectors that change form payloads.
Client-Side Storage
The client is inherently insecure. This means any browser API that you might use for storage is also not trustworthy. This includes localStorage and sessionStorage, both of which are visible to the user and can be changed by them. A common and typical example is the JWT that you might send back from your backend when you log in with a SPA.
Any tutorials you read will tell you not to store your JWT in the localStorage, but "we're just going to do it for this demo".
The biggest issue here is related to the vectors above. XSS attacks can and do check the browser APIs like localStorage for the presence of the right token, and will append it to the request, poking a massive hole in the security of the backend.
Any API that the browser can access a baddie can access too. This includes localStorage, sessionStorage, and even IndexedDB, all are vulnerable to their contents being seen, cloned or modified.
So how to mitigate that. Like other things on this list it's really about the backend, and then integrating it with the front. What you need to do is use a HttpOnly cookie. This means that the client can't actually see the content, just that there is a cookie. The server then loads that cookie, and pulls out the data, such as the JWT, verifying it and proceeding with the authorisation process.
Man-In-The-Middle Attacks
Man in the middle or MitM attacks are a broad category of attacks in which data is intercepted or modified as it travels between any two parties — ie, the user and the website. This can involve stealing sensitive information such as session cookies, the JWT or login details, or modifying the details of a transaction.
There are various kinds of attack. Passive eavesdropping just involves listening in, primarily to view login credentials. This vector is most dangerous in unsecured networks such as public wi-fi. Active man in the middle attacks instead modify transactions, such as changing the receiver of a transaction. Some forms of MitM strip out SSL security, making it look like the user is using HTTPS but the data is actually going via HTTP. Other versions spoof a free WiFi hotspot, or a DNS provider.
As has happened before, there are a few ways to mitigate these, and the backend is the most responsible. But as a frontend application there are definitely some best-practices to take. The critical one is that all applications, including frontends should always and only be https, they should always be using SSL.
The HttpOnly cookies that we mentioned earlier can also prevent MitM attacks gaining access to cookies and their contents. There is also the HTTP Strict Transport Security heading, which can be enabled to enforce HTTPS, blocking HTTP access. This can be optimised by submitting the domain to the Google HSTS proload list https://hstspreload.org/ which makes sure that any user that attempts to go to your website via http is redirected before even navigating, which makes a pre-emptive MitM on the first call impossible.
Supply Chain Attack
The JavaScript ecosystem is heavily dependent on third-party libraries, typically loaded through NPM. However, there's nothing to prevent an attacker or even a legitimate maintainer breaking bad from inserting any malicious code.
An example is the event-stream attack in 2018, where a hacker got access to the event-stream package on npm. They then injected a package, targeted at a specific application containing bitcoin account details and private keys, stealing an uknown amount of bitcoin.
These aren't the only vulnerabilities in supply chain. CDNs, CI/CD pipelines and renamed or spoofed dependendencies are all potential issues.
The mitigations for this are all the same — careful dependency management. CDNs should be avoided in general. Using and committing package-lock.json files (or the yarn.lock equivalent) prevents sneaky dependency changes, as does explicitly pinning dependencies.
Race Conditions in Frontend Logic
Sometimes a user doesn't have to hack or modify the request or access the localStorage to do something that wasn't intended. It can be enough to click the submit button twice. Or to enter the same 50% off coupon more than once.
This one isn't just security, it's also user experience. I think it's gotten better now, but back in the dark ages of the internet, users would routinely double-click buttons. A mistake like that shouldn't order two of the same Super Sonico Gothic Maid figures. One is enough.
The key here is that buttons should be disabled on submission, preventing re-submission. Uis cannot simply trust user inputs. There's no broad solution to this, as it's very much up to the individual app. But it's definitely something to be aware of as a developer.
The Big Solutions
A common thread runs through some of these mitigation strategies and solutions and that's access to the server. In some cases that means the backend API, but in others it means whatever infrastructure is serving the frontend. There are two big ticket solutions that we can implement that really help. Content Security Policies or CSP and CORS.
Content Security Policies: CSP
This will be the bigger discussion, and it could easily be an article of its own. The CSP is a specific header called Content-Security-Policy that lays out all the features of the site and what it will and won't allow. Browsers loading JavaScript applications on that domain will read the header and block activity based on the policy.
These headers are often quite long.
Content-Security-Policy:
default-src ‘self’;
script-src ‘self’ ‘unsafe-inline’ https://cdnjs.cloudflare.com;
style-src ‘self’ ‘unsafe-inline’ https://cdnjs.cloudflare.com;
img-src ‘self’ data: https://cdnjs.cloudflare.com;
font-src ‘self’ https://cdnjs.cloudflare.com;
connect-src ‘self’;
frame-ancestors ‘none’;
upgrade-insecure-requests;Content-Security-Policy:
default-src ‘self’;
script-src ‘self’ ‘unsafe-inline’ https://cdnjs.cloudflare.com;
style-src ‘self’ ‘unsafe-inline’ https://cdnjs.cloudflare.com;
img-src ‘self’ data: https://cdnjs.cloudflare.com;
font-src ‘self’ https://cdnjs.cloudflare.com;
connect-src ‘self’;
frame-ancestors ‘none’;
upgrade-insecure-requests;This example is a moderate security setting that does all the typical things, but allows inline scripts, and allows data that comes from the cloudflare cdn, allowing for our FontAwesome icons to work. Other examples might include exceptions for Google Fonts, or for Google Tag Manager or Analytics. Marketing tools tend to require a lot of these, as do some extensive third party integrations.
Some of the above isn't ideal or isn't necessary. The default-src is the fallback for the others, so in the case of connect-src it technically isn't needed. You can imagine if we didn't have the cloudflare in there we could probably ditch half the rules.
It's worth pointing out the last two of these. The frame-ancestors directive blocks the click jacking and other related framing exploits. The upgrade-insecure-requests directive forces https at all times, ensuring a secure and encrypted connection. The script-src directive being set to self blocks any XSS errors. There are a couple of more minor directives we can set like object-src 'none' which blocks old-school applets that might contain exploits, and base-url 'self' which blocks someone changing the base url to hack at form submission.
As a solid general rule you probably want to start with a minimal CSP and then poke in it the fewest holes you need for your functionality.
Content-Security-Policy:
default-src ‘self’;
object-src ‘none’;
base-uri ‘self’;
frame-ancestors ‘none’;
upgrade-insecure-requests;Content-Security-Policy:
default-src ‘self’;
object-src ‘none’;
base-uri ‘self’;
frame-ancestors ‘none’;
upgrade-insecure-requests;A common change might be style-src 'self' 'unsafe-inline; which will allow inline styles, which might be necessary for how your styling is setup — such as Styled Components.
CSP is a bit of a dark art. Getting it right in a real world, complex application can take some trial and error. There's a standing joke that CSP actually stands for "Completely Stops Pages" because it's not atypical for an overly aggressive CSP policy (apologies for the redundancy) to break a page's functionality or appearance in an unexpected way.
It's important to understand that the directives are not keys and values. The key is Content-Security-Policy and then the policy is everything else. So it should be written like this:
Content-Security-Policy: default-src ‘self’; object-src ‘none’; …Content-Security-Policy: default-src ‘self’; object-src ‘none’; …The CSP isn't set in an app like React. That's not where it lives. As said before it has to be the server. That might mean NextJS or Remix (and we'll get to that later) or Sveltekit, or it might mean the nginx config for a reverse proxy. It might be through the dashboard of a hosting solution like Digital Ocean.
Sites like Netlify and Vercel don't have a UI for handling this, but they do have built-in support. Netlify supports an _headers file that you can just dump the contents in there. Roughly the same applies to Vercel, which allows a vercel.json file that can include the headers.
While we're talking about headers, the CSP isn't the only header of value here.
Strict-Transport-Security, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, Cross-Origin-Resource-Policy, Cross-Origin-Opener-Policy, and Cross-Origin-Embedder-Policy are all headers that can and should be set to block most or all of the issues listed above all. I'd strongly recommend looking into them and setting them correctly.
Here's a crude _headers file with some common secure defaults. Note, by the way, that the /* is meaningful — it says to apply these settings to all routes.
/*
Content-Security-Policy: default-src ‘self’; object-src ‘none’;
base-uri ‘self’; frame-ancestors ‘none’; upgrade-insecure-requests;
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), microphone=(), camera=()
Cross-Origin-Resource-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp/*
Content-Security-Policy: default-src ‘self’; object-src ‘none’;
base-uri ‘self’; frame-ancestors ‘none’; upgrade-insecure-requests;
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), microphone=(), camera=()
Cross-Origin-Resource-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corpCross-Origin Resource Sharing — CORS
You would have noticed in our settings we spent a lot of time saying "self" and in our Cross-Origin-Resource-Policy above we say same-origin. Browsers want to limit access only to the local site — Same Origin Policy or SOP. Obviously limiting access to only the local site has security benefits, but at the minor issue of nothing ever working at all in practice.
In practice, especially in a single page app, we routinely request external resource, such as API payloads. Note that libraries like fetch are absolutely included in this SOP. Getting data from an external API? How very dare?!
The purpose of CORS is that it is intended to allow the server (note — the server not the client) to say what clients it's intended to allow to access it.
As it seems like always happens this is handled through a header.
Access-Control-Allow-Origin: https://frontend.comAccess-Control-Allow-Origin: https://frontend.comThis means only https://frontend.com can access the resource. Anything else and the browser will reject the request. In practice people often aren't that security minded and the header is completely open. In truth most of the time we set up CORS like this as a default to make it go away.
Access-Control-Allow-Origin: *Access-Control-Allow-Origin: *From a browser point of view and from a modern app point of view any requests you make to a server must return a CORS header, and one that your broswer can vibe with. This is check by running something called a preflight request, which is actually an OPTIONS request. By that I mean it uses an OPTIONS HTTP verb in place of POST, GET, etc. You don't see that one much, but it's valid.
The preflight check sends a bunch of headers where it checks whether the request's properites, including its Auth headers and method are going to be accepted. The server then replies with a response that looks like this.
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Authorization
Access-Control-Max-Age: 3600HTTP/1.1 204 No Content
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Authorization
Access-Control-Max-Age: 3600Basically these are just saying that these methods are allowed, any url, and the Authorization header is permitted. The final one is just to say that it doesn't need to resend a preflight check for another hour, just accept the current settings.
It's important to understand CORS, but it's not so important for a frontend developer to understand it in detail. As long as the server is set up correctly it should just work.
Owning the Backend
There's been a recurring theme here. There are things that you can and should do to secure the frontend. But there are diminishing returns if you have little or no control over the server. The more access you have to the server either as an infrastructure or an application the better you can configure your security.
I've gone into this a little bit previously, but there's a good solution for this. I wrote about the security benefits of something like NextJS as a Backend For Frontend, and this deeper discussion of security is a great example of why.
You can potentially have nginx config that stores this kind of setting, but that's not likely to be in your code repository, making it harder to transfer or reimplement. This can be a bigger problem than you might think with a complex CSP. I know this from experience.
The _headers file is a nice solution from Netlify, and vercel.json also allows the same functionality. But these are vendor-based solutions that rely on a specific hosting solution. NextJS and its next.config.js allows you to set a whole lot of features in an intuitive and secure way. It allows you to store a HttpOnly header in its server context, making a JWT secure. You can either setup CORS directly in the server or eliminate it because ther server IS the same origin. You can use built-in rate limiting to protect the API from excessive request.