Go back

Creating a Proof-of-Work reCaptcha alternative

Published on

Disclaimer: This is my first blog post ever. I'm not a native English speaker, so please excuse any mistakes.

Table of Contents

Introduction

I'm sure you've seen a reCaptcha before. It's a Google service that is used to verify that you're a human and not a bot. It's used on many websites and it's a great service, but it has some downsides.

First of all, it's a Google service. Google is a company that makes money by collecting data. All German companies and state institutions need to adhere to the DSGVO, which regulates the collection of personal data. This means that they can't use reCaptcha, because it collects personal data. (Just sending the IP-Address counts as personal data, which happens every time you request a website) This is a problem, because it means that they can't use the service that is used by most websites.

We could work around this problem by using a Cookie-Banner and block the reCaptcha Script until the user accepts the use of cookies. This is a bad solution, because A) it's annoying for the user and B) what happens if the user doesn't accept the use of cookies? We can't use reCaptcha but the server doesn't know that, so it will expect a reCaptcha response. This means that the user can't use the website but for public and state institutions this is illegal in Germany. Citizens need to be able to use the website of the instituion no matter what. (And of course, I, as a Citizen wouldn't like to be forced to accept cookies to contact my City about any problem I might have)

So I wanted to create a reCaptcha alternative that is easy to use, accessible, and doesn't track you. I came up with the idea of a Proof-of-Work reCaptcha. It's a reCaptcha that requires you to do some work to verify that you're a human. It's similar to the Proof-of-Work algorithm that is used in Bitcoin.

What is "Proof-of-Work"?

Proof-of-Work is a concept that is used in Bitcoin and other cryptocurrencies. It's a mechanism that is used to verify that a user is allowed to do something. In Bitcoin, it's used to verify that a user is allowed to add a new block to the blockchain. In my Proof-of-Work reCaptcha, it's used to verify that a user is allowed to send a form.

The idea is that the device (user or bot) has to do some work to prove that they're a human. (You can also call it the user needs to prove they're patient) This work takes some time and is computationally expensive. So you can't just send a lot of requests to the server, because you'd have to do a lot of work for each request which in turn would take a lot of time and resources. (CPU, RAM, ...)

The user has to compute a hash of a string which is called a "Puzzle". The hash has to start with a certain amount of zeros. The amount of zeros is called the difficulty. The difficulty is increased over time to make sure that the work is always expensive enough to prevent spam. (For example, we start with a difficulty of 5, so the hash has to start with 5 zeros. Then we increase the difficulty to 6, so the hash has to start with 6 zeros.)

Now you might think: "That's a great idea, but I would still get spam just not as much, right?". Well, yes, but actually no. The idea is that the work is expensive for the client, but cheap for the server. So the server can just increase the amount of work that the client has to do. This means that the server can always make sure that the work is expensive enough to prevent spam.

A short Example:

We have a user called "Alice" and a bot called "Bob". Alice wants to send a form to the server and Bob wants to send spam to the server. The server requires the client to do 10 hashes to verify that they're a human. Alice has a not so powerful device, so it takes her 5 seconds to do 10 hashes. For Alice this is fine, because she only has to do it once, she can leave the page after she is done.

Bob on the other hand has a very powerful device, so it only takes him 0.1 seconds to do 10 hashes. This means that he can send 100 requests per second. The server notices this and increases the amount of hashes that the client has to do. Now Bob can only send 1 request per minute.

This is a very simple example, but it shows how Proof-of-Work can be used to mitigate spam. Of course, this is not a perfect solution, but it's a good start.

Implementation

Now that we know what Proof-of-Work is, let's take a look at how we can implement it. I decided to use the SHA-256 hash function, because it's fast and secure enough for our purposes.

Next we need to decide what the "Puzzle" should be. I decided to use a string that contains the version, the difficulty, the current time, a unique id and a place of the nonce. So a puzzle could look like this:

v=1&d=5&t=1726353367636&u=1234567890&n=

To make the puzzle even more unique, we could add arbitrary data to it which we would check on the server. (Data from the user, like the IP-Address, the user agent or cryptographically secure pseudo random numbers) but for the sake of simplicity, we'll just use the data that I use above.

Now that we have the puzzle, we need to compute the hash on the client. We can do this by using the crypto.subtle.digest function. This function returns a promise that resolves to an ArrayBuffer. We can convert this ArrayBuffer to a hex string by using the hex function that I wrote.

We extract the difficulty from the puzzle and create a for-loop that increments the nonce. We then append the nonce to the puzzle and compute the hash. If the hash starts with the required amount of zeros, we're done. If not, we increment the nonce and try again. This is the code that I wrote:

const solveChallenge = async (challenge: string) => {
  if (typeof window === 'undefined') return;

  const challengeParams = new URLSearchParams(challenge);
  const difficulty = Number(challengeParams.get('d'));

  for (let nonce = 0; true; nonce++) {
    const prefix = `${challenge}${nonce}`;
    const hashResult = await sha256(prefix);

    if (!checkIfDifficultyIsMet(hashResult, difficulty)) {
      continue;
    }

    return {
      pow: hashResult,
      prefix: prefix,
    };
  }
};

Now that we have the hash, we can send it to the server. The server can then verify that the hash starts with the required amount of zeros. If it does, the server knows that the client did the work and is allowed to send the form. If it doesn't, the server knows that the client didn't do the work and is not allowed to send the form. Easy, right?

This work can be done in a Web Worker, so it doesn't block the main thread. This means that the user can still interact with the website while the client is doing the work this also means we can spin up multiple Web Workers to do the work in parallel.

Conclusion

I think that Proof-of-Work could be a great alternative to reCaptcha. It's easy to use, accessible, and doesn't track you. It's not perfect, but it's a good start. Mainly it was a fun project for me to learn more about cryptography and Web Workers.

It doesn't protect against bots that have a lot of resources or spam in general, reCaptcha by Google is still the best solution for that, they can use machine learning to detect bots. But for people that aren't suspect to targeted attacks by people with a lot of resources or those that don't want their users tracked by Google, this is a good enough alternative.

You can definitely improve the bot detection on the server by reading popular IP-Addresses and user agents from a database and checking if the user is using a VPN or Tor. And so on...

I had a lot of fun creating this project and I hope you enjoyed reading my first blog post. If you have any questions or feedback, feel free to contact me on X