Web security is a huge topic. The things you need to be aware of in order to keep your users (and yourself) safe can be overwhelming. Nevertheless, there are a few concepts and recommendations that solve the most important problems and are easy to learn and understand. Let’s take a look.
Security at the protocol level
According to Firefox, around 80% of page loads use HTTPs, so this is already a well-established practice. Using HTTPs allows your users to have security and privacy. It’ll encrypt the information between them and your servers, including passwords and other sensitive information such as email, physical addresses, etc. Years ago setting up HTTPs in your servers could be hard and expensive but now thanks to Let’s Encrypt it is a lot easier and free.
Use HTTP headers wisely
Browsers have the ability to enable some security mechanisms but only if you tell them to through HTTP headers. For example, you can tell the browser to forbid loading your website inside an iframe using the
X-Frame-Options header. This will prevent your users from being the target of clickjacking attacks.
Many of these headers and what they do can be found on the helmetjs website. Helmet.js is an excellent and easy-to-use library that allows you to enable these security mechanisms easily to express applications.
Most of these headers are easy to use and then we have the
Don’t leak information through HTTP status codes
If you use identifiers in URLs provided by users (e.g. http://example.com/my-super-secret-project-name) then when implementing authorization you shouldn’t return 403 if the resource exists but the user doesn’t have access to it. If you do this you are implicitly telling an attacker that the resource exists. In this case, you should return a 404 so the attacker doesn’t know whether the resource exists and they just don’t have access to it or it doesn’t exist at all.
Authentication is the most complex topic in web security in my opinion. You could write entire books about it and still not be able to cover everything. Nevertheless, there are a few aspects that are basic and not too hard to implement.
How to store users’ passwords
Of course, don’t store passwords in plain text. You need to store something in a secure way to be able to validate a user’s password when they log in. You don’t necessarily need to store the password itself but something that allows you to match against what the user is introducing in the login form. There’s a nice cryptographic primitive called hash that allows you to just do that.
A hashing function receives the plain text and outputs a value that you would normally store as hexadecimal characters. The thing is that calculating the plain text from the output is pretty hard. So even if somebody is able to steal your database they will have a hard time calculating the passwords from the hashes. But, how do you use a hashing function? It’s easy! When the user enters the password for the first time (or wants to change it) you store
hash(users_input) and when they log in you compare the stored value with the password they are providing
hash(password) == stored_value. But, you better use a timing safe comparison such as crypto.timingSafeEqual to avoid timing attacks.
This is a good start but there are a couple of additional things to keep in mind. First, you should salt the password, because plain hash functions will return the same output for the same input. This means that you could generate a list of hashes with the most common passwords like this,
hash('love') and compare it to what it’s stored in the database. If you are an attacker with a dump of the database this won’t give you everybody’s passwords but it’ll give you a lot of them!
This is called a rainbow table. In order to prevent this, you can generate a random number (called salt) that you can store in plain text near the password hash and then calculate the hashes with
hash(salt + password).
What else? Well, you should also either choose a slow hashing function or hash multiple times, because some hash functions are very fast, which is a security concern. Why? Because if an attacker is really interested in somebody’s password/access they might try with thousands or millions of passwords to see if they can crack the hash. In that case, you are making their work a lot simpler if the hash function is fast. However, if the hash function is slow (e.g. 300ms vs 10ms) you are making things way slower for them. Imagine 30x slower may mean taking 30 years instead of 1 year to crack it.
How to reset users’ passwords
Users tend to forget their passwords so you need a mechanism to allow them to identify themselves, somehow, and be able to set a new password. This can be tricky because you may be introducing a security breach depending on how you do it. Usually, you’d send an email to their email address providing a reset link. This link should expire and should have some randomness so an attacker cannot build reset links. Those are the two basic things to have in mind. Nevertheless, there are many other things that you may want to have into account and for that, I recommend this guide.
Delay wrong credentials responses
If you enter invalid credentials in the login screen of your operating system you may realize that it takes a bit to make the login form enabled again. Why is that? Simple, for the same reason we wanted our hash functions to be slow we want to mitigate brute force attacks by doing things a bit slower. So slow that for the user it’s not a big deal, but for the attacker, it is a big pain point.
Nevertheless, this would only stop a simple brute force attack that doesn’t do requests in parallel. For additional protection, you should rate-limit login attempts by IP, and if you want to go extra secure, in order to avoid brute force attacks to the same user from different IPs you should rate-limit by user account.
Complicated, huh? Yeah, like I said, you could write an entire book just dedicated to this topic. However, everything depends on how valuable the information you have is.
As you can see passwords can be problematic. Even if you do all the things right (like properly calculating and storing hashes, doing the reset functionality as secure as possible, etc) you simply cannot stop people from doing things like reusing the same password in many services or choosing a weak password that people close to them may guess. Are there any alternatives? Well, there are, here are a few:
- Use login links– instead of introducing your password, some applications (such as Slack) allow you to “send a magic link” which will give you access to the service. It’s like a reset link but for logging in
- Use a third-party service to implement authentication/authorization– there are services (such as Auth0) that take care of everything (including 2FA! which is pretty complicated to implement) and you just need to use their scripts and Hooks to start authenticating users
- Use a third-party provider such as Twitter, Facebook, GitHub– with this option you have less control than in the previous methods listed and not all of your users will have an account on those services so you may be leaving some users behind, but it’s another option and usually pretty simple to implement
Security at the application layer
Cross-site request forgery
This is one of the most common security vulnerabilities out there and it’s not that hard to fix. Let’s see how it works. The most common way of implementing session management consists of using cookies. Once a user is authenticated you set a cookie which is received by the browser and it sends it automatically in every request to the server. This is great and simple. However, let’s think about this. An attacker crafts a website with a hidden <form action=”vulnerablewebsite/malicious_action”>. Imagine it’s the website of a website to transfer goods or money and the attacker crafts a URL that, when submitted, will make the logged user transfer something to the attacker.
Now the attacker just needs to send a malicious link that contains the <form> to the victim. Once the victim visits the link, the form can be submitted even silently, and the request is automatically authenticated because the cookie with the auth information is sent by the browser. The attacker doesn’t need to even know the content of the cookie. And the malicious link can be a website hosted anywhere because browsers, by default, do not prevent forms from having URLs pointing to other domains.
How can we avoid this? The solution is to generate a token and put this token in a new cookie and in a hidden field in the form. Then when the form is submitted the backend will check if the token from the cookie is equal to the token in the form. An attacker doesn’t see the cookies so it’s unable to craft a form with a valid CSRF token.
If you are using express you can use the csurf package that will generate tokens, put them in cookies and validate them for you.
More great articles from LogRocket:
- Don't miss a moment with The Replay, a curated newsletter from LogRocket
- Use React's useEffect to optimize your application's performance
- Switch between multiple versions of Node
- Learn how to animate your React app with AnimXYZ
- Explore Tauri, a new framework for building binaries
- Compare NestJS vs. Express.js
- Discover popular ORMs used in the TypeScript landscape
This is maybe the most dangerous security vulnerability you may have and consists of modifying input parameters to manipulate poorly written queries in the application code. For example, if in your code you have:
query = "SELECT * FROM users WHERE login = '" + input_login + "';"
An attacker could send a malicious
input_login parameter in order to change the intent of the SQL query, even including multiple sentences separated by
;. With this mechanism, an attacker could bypass the user authentication or even delete records in your database.
The main mechanism to void this problem is by escaping the input parameters. Any good SQL library should have a way to achieve this. For example, the pg library allows you to do this:
const text = 'INSERT INTO users(name, email) VALUES($1, $2) RETURNING *' const values = ['brianc', '[email protected]'] const response = await client.query(text, values)
Instead of interpolating the values manually, you use placeholders ($1, $2), that the library will replace by the escaped version of the array of values.
To make sure you never forget to use placeholders you could set up a linter that catches manual interpolations and gives you an error.
Cross-site scripting (XSS)
So you need to escape the output. For example in EJS you’d do:
<div><%= message %></div>
<script>…</script>, the template engine will escape it to
<script>…</script> and the browser won’t evaluate the script content.
Be careful with external links
There’s a super simple attack that is also super simple to avoid and that’s why I wanted to mention it here. If you have a website that contains links to external websites because you put them there or because users can leave links in messages or their profile or anywhere, you are probably using
<a target="_blank"> to make those links open in a new window or tab. That’s nice, but it’s a potential security problem because the target website has access to the original tab by using
window.opener. The solution is as easy as using these values for the
<a href=”...” target=”_blank” rel=”noopener noreferrer”>Malicious link</a>
You should do this for any link with
Analyze your website
Nowadays there are also tools that allow you to catch problems, including security problems, easily. One of them is webhint. It has some rules that catch problems such as poor HTTP headers, vulnerable external links, etc.
There are also more advanced tools such as OWASP ZAP if you are interested in digging deeper into these topics.
Like I said, web security can be overwhelming, but I hope this article lets you understand the most common attacks and how to avoid or mitigate them. Let’s recap the most important things:
- Use HTTPs
- Use HTTP headers to mitigate some attacks
- Hash and reset passwords properly or go passwordless
- Use CSRF tokens
- Escape input parameters when doing SQL queries
- Sanitize and/or escape values in HTML templates
- Analyze your website!
LogRocket: Full visibility into your web and mobile apps
LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.