Skip to main content

Prevent form spamming

It's an often-heard complaint that online forms can be accessed by spammers (like spambots), resulting in lots of unwanted and unpleasant form entries. The main reason is that it's often relatively easy for spammers to understand the content and structure of simple online forms. As soon as a spammer knows this, it can start posting spam entries automatically.

To prevent spam entries, you often see 'solutions' that are difficult for spammers to fill out, like decoding unreadable CAPTCHAs, identifying traffic signs, or validating you're no robot.

Those solutions technically may work to prevent most spam entries, but it also works very well for something else: a highly decreased response rate! Nobody likes to fill out those questions, and often it takes longer to solve the puzzle than to fill out the form itself. Not very good for your user's experience, nor your response rate.

Tripetto contains an alternative solution to prevent (multiple) automatic submissions from spambots. It works by increasing the posting difficulty, making it harder or even impossible for a spambot to submit data. This guide shows how to implement this mechanism.

🎓 How it works​

Tripetto's antispam solution works by requiring a client (that wants to submit response data to your endpoint) to solve a cryptographic puzzle. Only when the client takes the time to solve the puzzle and include the right answer with the response, the data is accepted by the endpoint. The trick here is that the puzzle's complexity (or difficulty) can be adjusted based on heuristics defined by the endpoint. For example, we could increase complexity if many submissions are made by a certain client (IP address) or in a certain timeframe. This allows implementing an effective rate limiter without requiring work on the server-side (endpoint). Verifying the answer of the puzzle is very lightweight and does not consume much server resources. On the other hand, solving the puzzle is very resource expensive (especially when the difficulty raises). That's not a problem for a single valid response. But it is an issue for spambots making it less likely they target a Tripetto form.

A typical submission to an endpoint that supports this antispam technique follows these steps:

  • Step 1: A client wants to submit response data to an endpoint. Therefore it generates an announcement request that includes a checksum calculation of the response data;
  • Step 2: The endpoint receives the announcement, stores the checksum for later reference, and returns a response with an identifier, a difficulty number, and a timestamp. The difficulty number is essential here, as it specifies the complexity of the cryptographic puzzle. In a typical setup, the difficulty begins at 10 and then increases when necessary (for example, when the same IP address already submitted another response in a specific timeframe);
  • Step 3: The client receives the announcement response and needs to solve the cryptographic puzzle. The returned identifier, timestamp, and response data are all part of this puzzle. When the puzzle is solved, its outcome (a cryptographic hash) is submitted to the endpoint together with the actual response data;
  • Step 4: The endpoint then checks the answer to the puzzle. It also calculates the checksum based on the received response data to verify that the client submits the same data it announced in the announcement request (step 1). If all of these checks pass, the response data is stored by the endpoint. Optionally, the identifier (or reference) of the data on the server can be returned to the runner (more info about that here).
tip

Besides the steps above, you should also always validate the response data using the data stencil hash as described in the Validating response data guide.

🤖 Implementation​

The following code shows how to implement the mechanism described above. On the client it uses the powSolve function to solve the cryptographic puzzle. If you are using Node.js on the server endpoint, you can use the checksum and powVerify functions from the Runner library.

import { run } from "@tripetto/runner-autoscroll";
import { Export, checksum, powSolve } from "@tripetto/runner";

run({
definition: /* Supply your form definition here */,
onSubmit: (instance) =>
new Promise((resolve, reject) => {
// Prepare the response data
const data = {
exportables: Export.exportables(instance),
actionables: Export.actionables(instance),
};

// Post the announcement to the server
fetch("/announcement", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
// Generate the checksum for the data and announce it
checksum: checksum(data, true),
}),
}).then((response) => {
if (response.ok) {
const announcement = await response.json();

// Retrieve the id, difficulty and timestamp from the server announcement response
const id = announcement.id;
const difficulty = announcement.difficulty;
const timestamp = announcement.timestamp;

// Solve the puzzle
try {
const answer = powSolve(
data,
difficulty,
id,
16,
1000 * 60 * 5,
timestamp
);
} catch {
// Puzzle not solved, reject the promise
reject();
}

// Submit the response to the server
fetch("/submit", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
...data,
id,
answer,
}),
}).then((response) => {
if (response.ok) {
// All good, resolve the promise
resolve();
} else {
// Not so good, reject the promise
reject();
}
});
}
});
}),
});

👀 Examples​

If you want to see examples of this mechanism in production, have a look here: