How to Create Your Own CAPTCHA of Any Type: The Most Detailed Guide
If you use the internet often, you've probably had to prove you're human more than once. And now you're wondering: how can I create a CAPTCHA myself? There are plenty of ready-made solutions out there, but building your own is not only useful—it’s also really interesting! We’ve prepared an in-depth guide with clear instructions and step-by-step explanations to help you create your own protection against cyberattacks. Check it out and gain valuable experience in developing a powerful, user-friendly CAPTCHA system!
In this article:
If you're deciding whether to use a custom CAPTCHA or a ready-made solution (like Google’s reCAPTCHA), you need to consider usability, security level, and implementation complexity. The biggest advantage of custom solutions is that unique implementations make automated recognition much harder. The core technology might be similar, but you can always add your own twist.
In this guide, we’ll describe how to create several types of CAPTCHAs. Here’s a brief overview:
- Text CAPTCHA (entering text from an image)
+ Simple and suitable for small forms.
- Easily bypassed and can be annoying for users. - Image CAPTCHA (grid selection like Google reCAPTCHA)
+ Offers strong protection, often used in banking and voting systems.
- Not very mobile-friendly and may be difficult for some users. - Slider CAPTCHA
+ Fast and convenient for logins, especially on mobile devices.
- Can be easily bypassed by bots. - Puzzle CAPTCHA (dragging a puzzle piece into place)
+ Interactive and great for gaming or creative websites.
- Takes more time to complete.
You can also create audio CAPTCHAs and behavior-based verification.
To create an audio CAPTCHA, you can use the Web Audio API to generate and process sound, and HTML5 Audio for playback. If you need to generate audio on the server side, Node.js with libraries like fluent-ffmpeg is a good choice. You can also use Python with pydub or ffmpeg, or generate real-time audio with Java or C#.
For behavior-based CAPTCHAs, JavaScript is useful for tracking clicks, mouse movements, or scroll activity. Use WebSocket to send data to the server. Tools like FingerprintJS help create unique device fingerprints. On the server side, you can handle and analyze the data with Python (using Flask or Django) or C# (using ASP.NET).
When Is It Better to Use Ready-Made Systems (reCAPTCHA, Cloudflare, Amazon CAPTCHA, etc.)?
- When strong protection with advanced mechanisms is required
- If you don’t have the desire or resources to maintain your own CAPTCHA, or if you lack the technical skills for development and updates
- When scalability is needed (for example, services with millions of users)
- For better mobile user experience and integration with existing systems
So, for simple tasks or gaining experience, creating your own CAPTCHA can be a great option—especially if you plan to support and expand it later. But if protection is critical and requires a more sophisticated system, it’s better to go with the tried-and-true solutions.
If you’ve decided to build your own CAPTCHA, you’ll need to think through the core logic of how it works. You’ll need both the frontend – the client-side part that displays the CAPTCHA in the browser – and the backend – the server-side part that generates the CAPTCHA and verifies the user’s response. Let’s take a look at the general steps involved:
Layout the CAPTCHA form – create the interface with images, a slider, or text input.
Generate CAPTCHA data on the server – produce random characters, images, or puzzle pieces.
Display the CAPTCHA on the client side – load the data and adapt it to the user.
User interaction – track clicks, movements, and input.
Verify the user’s response on the server – compare the submitted data with the correct answer.
The choice of language depends on the type of CAPTCHA, the platform you're using, and the desired level of security.

For the client side (frontend), implementation is usually done using HTML/CSS/JavaScript. You can take a “vanilla” approach, or you can use frameworks and libraries such as React.js (or Vue, Svelte, Angular), Canvas/WebGL, and the following libraries:
dragula.js / interact.js – for drag-and-drop functionality (useful when a user needs to click on an element, drag it, and drop it into the correct spot)
anime.js / GSAP – for animations
fabric.js / p5.js – for drawing on canvas
CryptoJS – if you need to encrypt something on the client side (e.g., CAPTCHA data)

For the server side (backend), you can choose virtually any programming language. The main requirements are that it:
- Can accept requests from the client (usually via HTTP)
- Is able to process CAPTCHA validation logic
- Returns a response (success or failure)
Here’s a general outline of how it works on the server, regardless of the language:
- The client (browser) sends the CAPTCHA result (e.g., slider coordinates, token, text, etc.)
- The server checks: Is the response valid? Has the time limit expired? Is the IP address acceptable?
- The server responds: "Yes, human" or "No, bot"
Choose the programming language based on your preferences and the technologies you want to work with.

In our examples, we’ll be using a combination of HTML/CSS/JavaScript + Node.js. Our CAPTCHA systems will be framework-free, but as secure and user-friendly as possible. Here's why we chose this stack:
- Node.js is great for APIs and logic
Token generation, CAPTCHA response validation, session state storage, etc.
Its asynchronous model makes it efficient for handling multiple requests. - JavaScript in the browser is ideal for visual/interactive CAPTCHAs
Sliders, puzzles, image clicks — all are easy to implement on the frontend.
You can also detect suspicious behavior: mouse movements, timing, focus/blur events, and more. - Easy integration
CAPTCHAs built with this stack are easy to integrate into any web form and can be extended with sessions, rate limits, bot protection, and more.
Now that we’ve built a solid theoretical foundation and chosen our tools, it’s finally time to get hands-on and start building!
We’re going to build a basic version of a CAPTCHA, which you can later enhance and expand with more functionality.
- Create a folder with any name (for example, Textcaptcha), open it in your code editor, and create a file called index.html. Here, you’ll define the styles for the CAPTCHA (you can place them in a separate file named styles.css, and leave the HTML structure in index.html, which we’ll describe below). For example, your styles might look like this:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Text CAPTCHA</title>
<style>
/* Styles for the entire page body */
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: #f4f4f4;
margin: 0;
}
/* Styles for the CAPTCHA container */
.captcha-container {
background: white;
padding: 20px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
text-align: center;
width: 280px;
border-radius: 10px;
}
/* Styles for the CAPTCHA image */
.captcha-image {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 15px;
border: 2px solid #ddd;
padding: 10px;
background: #fff;
border-radius: 5px;
}
/* Styles for the CAPTCHA input field */
.captcha-input {
padding: 10px;
width: 100%;
font-size: 14px;
text-align: center;
border: 2px solid #ddd;
border-radius: 5px;
margin-bottom: 10px;
box-sizing: border-box;
}
/* Styles for the buttons (refresh and submit) */
.buttons {
display: flex;
justify-content: space-between;
align-items: center;
}
/* Styles for the CAPTCHA refresh button */
.refresh-button {
background: #007bff;
border: none;
padding: 5px;
border-radius: 50%;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
}
/* Icon size inside the button */
.refresh-button img {
width: 20px;
height: 20px;
}
/* Styles for the submit button */
.submit-button {
background: #28a745;
border: none;
color: white;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
}
/* Styles for the button when it's disabled */
.submit-button:disabled {
background: #ccc;
cursor: not-allowed;
}
/* Styles for the active submit button on hover */
.submit-button:hover:not(:disabled) {
background: #218838;
}
/* Styles for the CAPTCHA success message */
.success-message {
color: green;
display: none;
font-weight: bold;
margin-top: 10px;
}
/* Styles for the CAPTCHA error message */
.error-message {
color: red;
display: none;
font-weight: bold;
margin-top: 10px;
}
</style>
</head>
Let’s also add the main layout for the page:
<body>
<form class="captcha-container" onsubmit="return false;">
<div class="captcha-image">
<img id="captchaImage" src="" alt="CAPTCHA" width="200" height="80">
</div>
<input type="text" id="captchaInput" class="captcha-input" placeholder="Enter the text from the image" required />
<div class="buttons">
<button type="button" class="refresh-button" id="refreshButton">
<img src="https://cdn-icons-png.flaticon.com/512/61/61444.png" alt="Refresh">
</button>
<button type="submit" class="submit-button" id="submitButton" disabled>Verify</button>
</div>
<p class="success-message" id="successMessage">CAPTCHA passed!</p>
<p class="error-message" id="errorMessage">Incorrect input, please try again.</p>
</form>
<script src="script.js"></script>
</body>
</html>
Displaying the CAPTCHA: When the page loads, the <img id="captchaImage"> element will display the CAPTCHA image via JavaScript.
Refreshing the CAPTCHA: The "Refresh" button calls a function that updates the CAPTCHA image (this will be handled through JavaScript).
Verifying the CAPTCHA: After the user enters text into the input field and clicks the "Verify" button, the entered text is compared with the text in the CAPTCHA image. If the input is correct, a success message is shown; otherwise, an error message is displayed.
Input monitoring: The "Verify" button is only enabled once the user has entered text into the input field.
All CAPTCHA logic is implemented in an external JavaScript file (script.js).
2. Create another file named script.js — this is where the CAPTCHA will be loaded and sent to the (local) server for verification. When the page loads, the CAPTCHA will be automatically fetched.
- The user enters the text shown in the CAPTCHA image.
- When the "Verify" button is clicked, a request is sent to the server to check the input.
- If the input is correct, a success message is displayed and the button becomes inactive.
- If the input is incorrect, an error message is displayed and a new CAPTCHA is loaded.
- The user can manually refresh the CAPTCHA by clicking the "Refresh" button.
We’ll declare a variable captchaId to store the unique identifier of the CAPTCHA. This ID is used to verify the entered text against a specific CAPTCHA on the server:
let captchaId = "";
3. Now let's create the fetchCaptcha() function:
function fetchCaptcha() {
fetch("http://localhost:3000/generate-captcha")
.then((response) => response.json())
.then((data) => {
captchaId = data.captchaId;
document.getElementById("captchaImage").src = data.captchaImage;
})
.catch((error) => console.error("Error loading CAPTCHA:", error));
}
Here, we send a GET request to the server at http://localhost:3000/generate-captcha to get a new CAPTCHA image.
- .then((response) => response.json()): converts the server response to JSON format.
- .then((data) => {...}): processes the received data:
- captchaId = data.captchaId: assigns the captchaId variable the value from the server's response. This ID is used to verify the correctness of the entered text.
- document.getElementById("captchaImage").src = data.captchaImage: sets the src attribute of the CAPTCHA image element to the URL received from the server.
- .catch((error) => console.error("Ошибка загрузки капчи:", error)): if an error occurs during the request or response processing, it will be logged to the console.
4. Now let’s add an event listener for the submit button:
document.getElementById("submitButton").addEventListener("click", () => {
const userInput = document.getElementById("captchaInput").value.trim();
if (!userInput) return;
fetch("http://localhost:3000/verify-captcha", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userInput, captchaId }),
})
.then((response) => response.json())
.then((result) => {
if (result.success) {
document.getElementById("successMessage").style.display = "block";
document.getElementById("errorMessage").style.display = "none";
document.getElementById("submitButton").disabled = true;
} else {
document.getElementById("successMessage").style.display = "none";
document.getElementById("errorMessage").style.display = "block";
fetchCaptcha();
}
})
.catch((error) => console.error("Error verifying CAPTCHA:", error));
});
document.getElementById("submitButton").addEventListener("click", () => {...});
Adds an event listener to the "Verify" button, which sends the entered data to the server for verification.
const userInput = document.getElementById("captchaInput").value.trim();
Gets the value entered by the user from the input field and trims any extra spaces.
if (!userInput) return;
If the input field is empty, the handler stops execution.
fetch("http://localhost:3000/verify-captcha", {...});
Sends a POST request to the server to verify the entered CAPTCHA text.
The request includes:
userInput: the text entered by the user.
captchaId: the CAPTCHA identifier received earlier.
.then((response) => response.json())
Processes the server’s response (the CAPTCHA verification result).
.then((result) => {...});
If the CAPTCHA is successfully verified (result.success):
A success message is displayed.
The "Verify" button becomes disabled (submitButton.disabled = true).
In case of an error:
An error message is displayed.
The fetchCaptcha() function is called to load a new CAPTCHA image.
5. Now, let's add an event listener for the CAPTCHA refresh button:
document
.getElementById("refreshButton")
.addEventListener("click", fetchCaptcha);
Clicking this button will trigger the fetchCaptcha() function, which loads a new CAPTCHA image.
6. Input field event listener:
document.getElementById("captchaInput").addEventListener("input", (e) => {
document.getElementById("submitButton").disabled =
e.target.value.trim().length === 0;
});
fetchCaptcha();
On every text change in the input field (input event):
The length of the entered text is checked.
If the text is empty, the "Verify" button becomes disabled (submitButton.disabled = true).
If the text is not empty, the "Verify" button becomes enabled (submitButton.disabled = false).
7. Great — styles and the frontend code for displaying and verifying the CAPTCHA are done! Now let's move on to the server-side, where the CAPTCHA will be generated and the user's response will be verified.
Create a file named server.js, open your terminal, and install the following dependencies:
npm install express cors uuid canvas
What will we use?
- express – a framework for Node.js that simplifies the creation of web servers.
- cors – middleware for allowing cross-origin requests (to work with the frontend).
- uuid – for generating unique identifiers (to generate captchaId).
- canvas – a library for working with graphics (to create the CAPTCHA).
8. Now, let's import the installed dependencies, create an instance of the server, and set up the middleware:
import express from "express";
import cors from "cors";
import { v4 as uuidv4 } from "uuid";
import { createCanvas } from "canvas";
const app = express();
app.use(cors());
app.use(express.json());
9. Let's create a storage for the CAPTCHA. We'll declare the captchaStore object to store CAPTCHA data, such as identifiers and text:
const captchaStore = {};
10. Let's generate the CAPTCHA text. The generateCaptchaText function generates a random string consisting of 6 characters (letters and digits):
function generateCaptchaText() {
return Math.random().toString(36).substring(2, 8).toUpperCase();
}
11. How about adding a random color? 😀 The getRandomColor function generates a random color in RGB format:
function getRandomColor() {
const r = Math.floor(Math.random() * 256);
const g = Math.floor(Math.random() * 256);
const b = Math.floor(Math.random() * 256);
return `rgb(${r},${g},${b})`;
}
12. We will also generate a font:
function getRandomFont() {
const fonts = ["Arial", "Verdana", "Courier", "Georgia", "Times New Roman"];
const randomFont = fonts[Math.floor(Math.random() * fonts.length)];
const randomSize = Math.floor(Math.random() * 10) + 30; // Font size from 30 to 40
return `${randomSize}px ${randomFont}`;
}
13. Let's add an image to the text, noise, and distortions:
function generateCaptchaImage(text) {
const canvas = createCanvas(200, 80);
const ctx = canvas.getContext("2d");
// Fill background
ctx.fillStyle = "#f8f8f8";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Add distortions (noise, lines)
for (let i = 0; i < 5; i++) {
ctx.strokeStyle = `rgba(0, 0, 0, ${Math.random()})`;
ctx.beginPath();
ctx.moveTo(Math.random() * 200, Math.random() * 80);
ctx.lineTo(Math.random() * 200, Math.random() * 80);
ctx.stroke();
}
// Set font and text color
ctx.font = getRandomFont();
ctx.fillStyle = getRandomColor();
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate(Math.random() * 0.3 - 0.15); // Slight text tilt
ctx.fillText(text, 0, 0);
return canvas.toDataURL(); // Return CAPTCHA as base64 image
}
Let's move on to the API:
14. API for captcha generation. In this API handler, we generate the captcha, store the text and ID, and send the data to the user:
app.get("/generate-captcha", (req, res) => {
const captchaText = generateCaptchaText();
const captchaId = uuidv4();
captchaStore[captchaId] = captchaText;
const captchaImage = generateCaptchaImage(captchaText);
res.json({ captchaId, captchaImage });
});
15. API for captcha verification. In this API handler, we accept the user's input and compare it with the stored captcha text:
app.post("/verify-captcha", (req, res) => {
const { userInput, captchaId } = req.body;
if (!captchaId || !captchaStore[captchaId]) {
return res
.status(400)
.json({ success: false, message: "CAPTCHA expired or not found" });
}
const isCorrect = userInput.toUpperCase() === captchaStore[captchaId];
delete captchaStore[captchaId]; // Remove CAPTCHA after verification
res.json({
success: isCorrect,
message: isCorrect ? "CAPTCHA is correct" : "CAPTCHA is incorrect",
});
});
Here we:
- Get the userInput (entered text) and captchaId from the user.
- Check if a captcha with that ID exists in the storage.
- If the captcha exists, compare the input with the stored text and send the result (correct or incorrect).
16. Finally, we start our server on port 3000!
app.listen(3000, () => console.log("Server is running on port 3000"));
In the terminal, run the server with the command: node server.js and open our project in the browser. Hooray, the captcha is created and working perfectly!

There is also a type of captcha called a slider – where you need to move a slider to a certain position on the form – for example, to the right. Let's go ahead and create it ourselves!
- Create a new folder called "SimpleSlider," and in your code editor, create an index.html file. In this file, we'll add the main structure of the page, including the container for the captcha, the slider, and the buttons. We'll also add styles for the slider, buttons, success and error messages, and embed CSS to style the page and center the elements.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Simple Slider</title>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: #f4f4f4;
margin: 0;
}
.captcha-container {
background: white;
padding: 20px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
text-align: center;
width: 280px;
border-radius: 10px;
}
.slider {
width: 100%;
height: 40px;
background: #ddd;
position: relative;
border-radius: 5px;
overflow: hidden;
margin-top: 10px;
}
.slider-button {
width: 40px;
height: 40px;
background: #007bff;
position: absolute;
top: 0;
left: 0;
border-radius: 5px;
cursor: pointer;
}
.success-message,
.error-message {
font-weight: bold;
display: none;
margin-top: 10px;
}
.success-message {
color: green;
}
.error-message {
color: red;
}
</style>
</head>
<body>
<form class="captcha-container" onsubmit="return false;">
<p>Drag the slider to the right</p>
<div class="slider" id="slider">
<div class="slider-button" id="sliderButton"></div>
</div>
<p class="success-message" id="successMessage">CAPTCHA passed!</p>
<p class="error-message" id="errorMessage">Error, please try again.</p>
</form>
<script src="script.js"></script>
</body>
</html>
2. Now let's create a JavaScript file called script.js, which will handle the slider functionality, dragging, and verification through the server. We'll create a variable captchaSession, which will store the ID of the current captcha session received from the server, so we can later use this ID for validation on the server:
let captchaSession = "";
3. Loading the captcha session from the server.
function fetchCaptchaSession() {
fetch("http://localhost:3000/generate-slider-captcha")
.then((response) => response.json())
.then((data) => {
captchaSession = data.sessionId;
})
.catch((error) => console.error("Captcha loading error:", error));
}
Here we:
- Make a fetch request to http://localhost:3000/generate-slider-captcha.
- Expect a JSON response from the server containing a sessionId field.
- Store this sessionId in the captchaSession variable.
- If an error occurs, it logs a message to the console.
This is needed so that later we can send the sessionId for verification in verify-slider.
4. Next, we access the necessary HTML elements:
const slider = document.getElementById("slider");
const button = document.getElementById("sliderButton");
const successMessage = document.getElementById("successMessage");
const errorMessage = document.getElementById("errorMessage");
let isDragging = false;
let sliderCompleted = false;
- isDragging – tracks whether the dragging mode is active.
- sliderCompleted – prevents repeated actions if the captcha has already been passed.
5. Now, let's handle the mouse click on the slider:
button.addEventListener("mousedown", () => {
if (sliderCompleted) return;
isDragging = true;
});
When the mouse button is pressed on the slider, it checks whether the user has already completed the captcha. If not, it enables the dragging mode (isDragging = true).
6. Slider movement:
document.addEventListener("mousemove", (e) => {
if (!isDragging) return;
let rect = slider.getBoundingClientRect();
let offsetX = e.clientX - rect.left;
if (offsetX < 0) offsetX = 0;
if (offsetX > rect.width - button.offsetWidth)
offsetX = rect.width - button.offsetWidth;
button.style.left = offsetX + "px";
});
When the mouse moves, we check if drag mode is enabled, calculate the mouse coordinates relative to the slider, and limit the value within the slider boundaries. Then, we set the left property of the button (slider) to make it move.
7. We complete the action when the mouse button is released:
document.addEventListener("mouseup", () => {
if (!isDragging) return;
isDragging = false;
let rect = slider.getBoundingClientRect();
let finalPosition = parseInt(button.style.left);
We check if dragging occurred, turn off the isDragging mode, and get the final position of the slider. Then, we check if the slider has reached the end:
if (finalPosition >= rect.width - button.offsetWidth - 5) {
8. Sending to the server (successful execution):
fetch("http://localhost:3000/verify-slider", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId: captchaSession, completed: true }),
})
We send a POST request to /verify-slider. In the request body, we specify:
- sessionId – the session ID.
- completed: true – the user has moved the slider to the end.
9. Handling the response:
If the server responds with success: true:
- Display a success message.
- Change the button's color.
- Mark the captcha as completed.
If the server responds with an error:
- Display an error message.
- Reset the slider.
- Load a new captcha.
.then((response) => response.json())
.then((result) => {
if (result.success) {
successMessage.style.display = "block";
errorMessage.style.display = "none";
button.style.background = "green";
sliderCompleted = true;
} else {
successMessage.style.display = "none";
errorMessage.style.display = "block";
button.style.left = "0px";
fetchCaptchaSession();
}
})
10. We can also add error handling for the request: reset the slider and request a new captcha.
.catch((error) => {
console.error("Error during captcha verification:", error);
errorMessage.style.display = "block";
button.style.left = "0px";
fetchCaptchaSession();
});
11. If the slider hasn't reached the end, and the user hasn't completed it, the slider is reset back to the left:
} else {
button.style.left = "0px";
}
});
fetchCaptchaSession();
12. Now let's move on to the server-side part. We'll create a file called server.js in the same directory, open the terminal, and install the necessary dependencies:
npm install express cors body-parser
- express is needed to run the HTTP server.
- cors allows cross-origin requests.
- body-parser is used to process the JSON body of POST requests.
13. We'll import them into our project:
import express from "express";
import cors from "cors";
import bodyParser from "body-parser";
14. Now let's set up the server. We'll create an instance of the Express application and specify the port (e.g., 3000):
const app = express();
const PORT = 3000;
15. We'll allow requests from other domains, set Express to handle JSON in the request body, and define the captcha session storage: a Map will be used to store temporary sessions (in the future, Redis might be a better choice). SESSION_TTL defines the session lifetime: 5 minutes.
const sliderSessions = new Map();
const SESSION_TTL = 5 * 60 * 1000;
16. We'll clean up expired sessions. Every minute, a check will run to evaluate all sessions, and if a session has expired (older than 5 minutes), it will be removed:
function cleanUpSessions() {
const now = Date.now();
for (const [sessionId, { timestamp }] of sliderSessions) {
if (now - timestamp > SESSION_TTL) {
sliderSessions.delete(sessionId);
}
}
}
setInterval(cleanUpSessions, 60 * 1000); // Run every minute
17. Creating a New CAPTCHA. We generate a sessionId, which is stored in memory along with the current timestamp. The client receives the sessionId, which will be used for verification later:
app.get("/generate-slider-captcha", (req, res) => {
const sessionId = Math.random().toString(36).substring(2, 15);
sliderSessions.set(sessionId, { completed: false, timestamp: Date.now() });
console.log(`New session: ${sessionId}`);
res.json({ sessionId });
});
18. CAPTCHA Verification and Server Start. We receive the sessionId and completed (a boolean value) from the request body.
If the session is not found – return an error.
If the user successfully completed the slider – update the session and return a success response:
app.post("/verify-slider", (req, res) => {
const { sessionId, completed } = req.body;
if (!sessionId || !sliderSessions.has(sessionId)) {
return res
.status(400)
.json({ success: false, message: "Session not found" });
}
if (completed) {
sliderSessions.set(sessionId, { completed: true, timestamp: Date.now() });
console.log(`Captcha passed: ${sessionId}`);
return res.json({ success: true });
} else {
return res
.status(400)
.json({ success: false, message: "Captcha not passed" });
}
});
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
19. Start the server by running the command node server.js in the terminal and open the project in your browser. And there you have it — a simple yet functional slider CAPTCHA!

If you store everything in memory, it’s fast but unreliable—if the server crashes, everything is lost. That’s why it’s better to use Redis for caching data. It’s fast, supports TTL (time-to-live), and is perfect for temporary data like CAPTCHAs and tokens.
Here’s a general flow: you generate a CAPTCHA → save it in Redis with an ID → send that ID to the user via cookie or query → the user solves the CAPTCHA → you validate it → then issue (or don’t issue) a token. The token is also stored in Redis so it can be verified later during login or form submission.
Cookies are useful for remembering: “This user has already passed the CAPTCHA.” You can set the cookie to last 10 minutes or 1 hour—whatever suits your case. After that time, the CAPTCHA will be shown again.
You can also add timers to prevent spam, IP rate limits, and other useful automation—Redis handles all of this very well.
Redis is excellent for temporary storage, but sometimes you need long-term stats: how many times a user solved a CAPTCHA, from where, how often they failed, what type of CAPTCHA was used, etc. That’s where you should bring in a database—PostgreSQL, MongoDB, or even MySQL.
A database is essential for logging, analytics, and monitoring suspicious behavior.
Additional Parameters
Besides captchaId, CAPTCHAs usually come with extra parameters like:

What to Do with This
Add these fields to the CAPTCHA object when it’s generated, and store them in both Redis and your database. During CAPTCHA validation, update the status (success/failed) and increment the attempt count. On success, you can even store an authorization token in Redis, linked to the captchaId or IP address. If set up correctly, you’ll end up with not just a protection mechanism but a complete anti-bot control system — and optionally, a stats panel showing who solved which CAPTCHA, when, where, and how.
To Strengthen CAPTCHA Security, You Can Also Add:
- Noise, distortion, and random parameters (as we did in the text CAPTCHA).
- Mouse movement analysis (for slider/puzzle types).
- Obfuscation and encryption.
Do not send coordinates or hashes to the client. Instead, sign the data (for example, using JWT).
Let’s move from theory to practice! Imagine a project where logging in requires not only entering a username and password but also solving a CAPTCHA in the form of a puzzle. In this CAPTCHA, the user has to drag a puzzle piece (which we’ll cut from the image in the project) into the correct position on the background. The server checks the CAPTCHA token, and only if the CAPTCHA is passed and the login credentials are valid, the user is successfully authenticated.
Let’s get to work!
First, let’s prepare all the necessary tools and files. Create a folder named Puzzleslider and open it in your code editor. Now add a background image for the CAPTCHA, sized 320x180 pixels, to an images folder. This will serve as the base for our puzzle.
Since we want to manage sessions and tokens using Redis, install Redis on your computer and start the Redis server (you can find all the necessary instructions on the official website).
Running redis-server on Windows:

3. Now, in the Puzzleslider folder, let's create a file named index.html and add styles using inline CSS along with the markup for the page:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Captcha + Authentication</title>
<style>
body {
font-family: Arial, sans-serif;
background: #f4f4f4;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
.container {
background: white;
padding: 20px;
width: 320px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.captcha-image {
width: 100%;
height: 180px;
position: relative;
margin-bottom: 10px;
}
canvas {
position: absolute;
top: 0;
left: 0;
}
.slider {
width: 100%;
height: 40px;
background: #ddd;
border-radius: 5px;
position: relative;
cursor: pointer;
}
.slider-button {
width: 40px;
height: 40px;
background: #007bff;
position: absolute;
top: 0;
left: 0;
border-radius: 5px;
transition: left 0.2s;
}
.success,
.error {
margin-top: 10px;
font-weight: bold;
}
input {
width: 100%;
padding: 10px;
margin-top: 10px;
box-sizing: border-box;
}
button {
width: 100%;
margin-top: 10px;
padding: 10px;
background: #28a745;
border: none;
color: white;
cursor: pointer;
font-weight: bold;
}
</style>
</head>
<body>
<div class="container">
<h3>Captcha Verification</h3>
<form id="captchaForm">
<div class="captcha-image">
<canvas id="puzzleCanvas"></canvas>
<canvas id="puzzlePieceCanvas"></canvas>
</div>
<div class="slider" id="slider">
<div class="slider-button" id="sliderButton"></div>
</div>
<p class="success" id="captchaStatus"></p>
</form>
<h3>Login</h3>
<form id="loginForm">
<input type="text" id="username" placeholder="Username" />
<input type="password" id="password" placeholder="Password" />
<button id="loginButton" type="submit">Log in</button>
<p class="error" id="loginStatus"></p>
</form>
</div>
<script src="script.js"></script>
</body>
</html>
This code creates a page with two main sections: a CAPTCHA form and a login form. The first section contains the CAPTCHA, which consists of two canvases—one for the full image and one for the cut-out puzzle piece—as well as a slider to verify the solution. The second section includes the form for entering a username and password, along with a "Login" button. Each section is structured using HTML elements, and interactions with them will be handled via JavaScript in an external file called script.js.
The whole process on the frontend consists of three main parts:
- CAPTCHA generation and verification
- Slider movement and position checking
- Submitting the login form after successful CAPTCHA validation
4. We’ll implement the functionality for the puzzle CAPTCHA and the secure login form. For this, we’ll create a new file called script.js and initialize the following variables:
let sessionId = "";
let cutoutX = 0;
let cutoutY = 0;
let isDragging = false;
const puzzleWidth = 320;
const puzzleHeight = 180;
const pieceSize = 50;
- sessionId: stores the unique session identifier used to validate the CAPTCHA on the server.
- cutoutX, cutoutY: coordinates of the image section that will be cut out and turned into the puzzle piece.
- isDragging: a flag to track whether the slider is being dragged.
- puzzleWidth, puzzleHeight, pieceSize: define the dimensions of the CAPTCHA image and the puzzle piece.
5. Next, we’ll grab the HTML elements that JavaScript will interact with.
const puzzleCanvas = document.getElementById("puzzleCanvas");
const puzzlePieceCanvas = document.getElementById("puzzlePieceCanvas");
const slider = document.getElementById("slider");
const sliderButton = document.getElementById("sliderButton");
const captchaStatus = document.getElementById("captchaStatus");
const loginButton = document.getElementById("loginButton");
puzzleCanvas and puzzlePieceCanvas are the canvases used to display the main image and the cut-out puzzle piece.
slider and sliderButton are the slider elements that the user will drag to solve the CAPTCHA.
captchaStatus is an element for displaying messages about the CAPTCHA status.
loginButton is the button used to submit the login form.
6. Now, let's create a function to request a new CAPTCHA:
function fetchCaptcha() {
sessionId = "";
fetch("http://localhost:3000/generate-puzzle-captcha")
.then(res => res.json())
.then(data => {
sessionId = data.sessionId;
cutoutX = data.cutoutX;
cutoutY = data.cutoutY;
const bgImg = new Image();
bgImg.src = `${data.backgroundImage}?${Date.now()}`;
bgImg.onload = () => drawCaptcha(bgImg);
resetState();
})
.catch(err => console.error("Captcha error:", err));
}
fetchCaptcha() sends a request to the local server (http://localhost:3000/generate-puzzle-captcha) to retrieve data needed to generate a new CAPTCHA.
The server responds with the following data: sessionId (session identifier), cutoutX, cutoutY (coordinates for cutting out a piece of the image), and backgroundImage (URL of the background image).
The image is loaded, and once it’s ready, the drawCaptcha function is called to render the CAPTCHA on the canvas.
Calling resetState() resets the interface state.
7. Drawing the CAPTCHA and cutting out the puzzle piece:
function drawCaptcha(img) {
puzzleCanvas.width = puzzlePieceCanvas.width = puzzleWidth;
puzzleCanvas.height = puzzlePieceCanvas.height = puzzleHeight;
const bgCtx = puzzleCanvas.getContext("2d");
const pieceCtx = puzzlePieceCanvas.getContext("2d");
bgCtx.drawImage(img, 0, 0, puzzleWidth, puzzleHeight);
pieceCtx.clearRect(0, 0, puzzleWidth, puzzleHeight);
pieceCtx.drawImage(
img, cutoutX, cutoutY, pieceSize, pieceSize,
0, 0, pieceSize, pieceSize
);
bgCtx.clearRect(cutoutX, cutoutY, pieceSize, pieceSize);
}
- The full image is drawn on puzzleCanvas.
- Only the cut-out piece (of size pieceSize) is drawn on puzzlePieceCanvas.
- Then, the area of the cut-out on the CAPTCHA background (at cutoutX and cutoutY) is cleared.
8. Resetting the CAPTCHA state — the slider and puzzle piece are returned to their initial positions. The login button is also disabled until the CAPTCHA is successfully completed:
function resetState() {
sliderButton.style.left = "0px";
puzzlePieceCanvas.style.left = "0px";
puzzlePieceCanvas.style.top = `${cutoutY}px`;
captchaStatus.textContent = "";
loginButton.disabled = true;
}
9. Handling the start of slider dragging:
sliderButton.addEventListener("mousedown", () => {
isDragging = true;
const rect = slider.getBoundingClientRect();
const sliderLeft = rect.left;
const move = (e) => {
if (!isDragging) return;
let x = e.clientX - sliderLeft - 20;
x = Math.max(0, Math.min(x, slider.offsetWidth - 40));
sliderButton.style.left = `${x}px`;
puzzlePieceCanvas.style.left = `${x}px`;
puzzlePieceCanvas.style.top = `${cutoutY}px`;
};
const stop = () => {
isDragging = false;
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseup", stop);
const userX = parseInt(puzzlePieceCanvas.style.left, 10);
fetch("http://localhost:3000/verify-puzzle", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId, position: userX }),
})
.then(res => res.json())
.then(data => {
if (data.success) {
localStorage.setItem("captchaToken", data.token);
captchaStatus.textContent = "Captcha passed ✅";
captchaStatus.style.color = "green";
loginButton.disabled = false;
} else {
captchaStatus.textContent = "Incorrect. Please try again.";
captchaStatus.style.color = "red";
setTimeout(fetchCaptcha, 1000);
}
});
};
document.addEventListener("mousemove", move);
document.addEventListener("mouseup", stop);
});
When the user presses the slider button (mousedown), the dragging process begins.
Mouse movement is tracked (mousemove), and both the slider and the puzzle piece move in sync.
When the user releases the mouse button (mouseup), the slider’s offset is checked, and a request is sent to the server to verify whether the CAPTCHA was solved correctly.
10. Login:
document.getElementById("loginButton").addEventListener("click", (event) => {
event.preventDefault();
const username = document.getElementById("username").value;
const password = document.getElementById("password").value;
const captchaToken = localStorage.getItem("captchaToken");
fetch("http://localhost:3000/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password, captchaToken }),
})
.then((res) => res.json())
.then((data) => {
const status = document.getElementById("loginStatus");
if (data.success) {
status.textContent = "Login successful!";
status.style.color = "green";
} else {
status.textContent = "Error: " + data.message;
status.style.color = "red";
}
})
.catch((err) => {
const status = document.getElementById("loginStatus");
status.textContent = "Error during authentication.";
status.style.color = "red";
});
});
fetchCaptcha();
});
- When the user clicks the login button, a request is sent to the server with the login data (username, password, and CAPTCHA token).
- If the login is successful, a success message is displayed; otherwise, an error message is shown.
11. The CAPTCHA styles and all required client-side code are ready — now we can move on to the server part. This includes storing session and token data, generating the CAPTCHA, verifying it, and handling user authentication with mandatory CAPTCHA validation via token.
Create a new file called server.js, open the terminal, and install the necessary tools:
npm install express cors body-parser @redis/client uuid
- express: A framework for building the server.
- cors: A module for configuring CORS (Cross-Origin Resource Sharing), which allows the server to accept requests from other domains.
- body-parser: For parsing JSON in request bodies.
- @redis/client: The Redis library for interacting with the Redis database (which we previously installed and started with redis-server).
- uuid: For generating unique session IDs and tokens.
12. Import dependencies and configure the server:
import express from "express";
import cors from "cors";
import bodyParser from "body-parser";
import { createClient } from "@redis/client";
import { v4 as uuidv4 } from "uuid";
const app = express();
const port = 3000;
app.use(cors());
app.use(bodyParser.json());
app.use(express.static("images"));
- express() creates an instance of the Express application.
- port = 3000 sets the server to listen on port 3000.
- app.use(cors()) enables CORS, allowing cross-origin requests.
- app.use(bodyParser.json()) configures the server to parse JSON request bodies.
- app.use(express.static("images")) tells Express to serve static files from the images folder (used to serve the previously uploaded 320×180 image).
13. Connect to Redis:
const redisClient = createClient();
redisClient.on("error", (err) => console.log("Redis error:", err));
redisClient.connect();
14. Generating and verifying the CAPTCHA:
app.get("/generate-puzzle-captcha", async (req, res) => {
const sessionId = uuidv4();
const cutoutX = Math.floor(Math.random() * (320 - 50));
const cutoutY = Math.floor(Math.random() * (180 - 50));
await redisClient.set(sessionId, JSON.stringify({ cutoutX, cutoutY }), { EX: 300 });
res.json({
sessionId,
cutoutX,
cutoutY,
backgroundImage: "images/captcha-background.jpg",
});
});
app.post("/verify-puzzle", async (req, res) => {
const { sessionId, position } = req.body;
const session = await redisClient.get(sessionId);
if (!session) return res.status(400).json({ success: false });
const { cutoutX } = JSON.parse(session);
if (Math.abs(position - cutoutX) <= 10) {
const token = uuidv4();
await redisClient.set(`captcha-token:${token}`, "valid", { EX: 300 });
return res.json({ success: true, token });
} else {
return res.json({ success: false });
}
});
Here, a POST /verify-puzzle route is created to validate the CAPTCHA solution.
sessionId and position (slider position) are sent in the request body.
const session = await redisClient.get(sessionId);
This retrieves the session data from Redis. If the session doesn't exist, a 400 error is returned.
const { cutoutX } = JSON.parse(session);
This extracts the cutout coordinates from the session.
The submitted position is compared to the correct one using:
Math.abs(position - cutoutX) <= 10
If the difference is within 10 pixels, the CAPTCHA is considered passed.
If the CAPTCHA is passed, a token is generated and saved in Redis so it can be used for further actions (the token is valid for 300 seconds).
A response containing the token is then sent back to the client.
15. Authentication with CAPTCHA validation:
app.post("/login", async (req, res) => {
const { username, password, captchaToken } = req.body;
if (!captchaToken) {
return res.status(400).json({ success: false, message: "No captcha token" });
}
// Check the validity of the captcha token
const validCaptcha = await redisClient.get(`captcha-token:${captchaToken}`);
if (!validCaptcha) {
return res.status(403).json({ success: false, message: "Captcha not passed" });
}
// Example of validation
if (username === "test" && password === "password") {
res.json({ success: true });
} else {
res.status(401).json({ success: false, message: "Invalid credentials" });
}
});
app.listen(port, () => console.log(`Server running at http://localhost:${port}`));
In this part, a POST /login route is created to handle the login request.
It first checks for the presence of captchaToken in the request body. If the token is missing, an error is returned.
const validCaptcha = await redisClient.get(...);
This checks whether the token exists in Redis.
If the token is not found, it means the CAPTCHA has not been passed, and a 403 error is returned.
If the CAPTCHA is verified, the server proceeds to check the username and password. Let's assume the valid credentials are text for the username and password for the password, which we define in:
if (username === ...)
If the provided credentials match the predefined ones, a success response is returned.
If the credentials are incorrect (e.g., wrong username or password), a 401 error is returned.
To view all sessions and keys stored in Redis, you can use the command KEYS *.
For example:

Let's check that the Redis server is running and working correctly. Run server.js with the command node server.js and open the project in a browser. Try testing the login form and captcha — if everything is done correctly, solving the captcha and entering the correct data will result in the following view:

We won’t be able to log in until we solve the captcha and enter the correct username and password:

Our code works great! But we can take it a step further and create a solution where the user doesn’t have to solve the captcha every time. This can be done by adding the use of cookies in the user’s browser. We suggest you consider the following example and try to develop a full-featured captcha with token and session storage along with cookie support:
Let’s write code that creates an image captcha system where the user needs to select pictures belonging to a specific category — for example, cars, animals, or nature. When the user visits the page, they will receive a set of images and will need to select those that fit the category. The server will generate the captcha by selecting random images and creating a unique verification code. After the user selects the images and clicks the button, the result is sent to the server to check if the correct images were chosen. To prevent the user from having to solve the captcha again if they already passed it, data is stored in Redis and a cookie is used to track whether they have passed the captcha. Yes, this approach is a bit more complex than previous examples, but the results are worth it! Let’s start with the preparation:
- First, create a folder with any convenient name (in our example, it’s Gridcaptcha). Inside, create an images folder and add several images sized 150x150 or 100x100 pixels. Start with at least 9 images — the more, the more diverse and challenging the captcha will be! The images should be grouped by category, for example: vehicle, animal, and nature. Name them accordingly, e.g., car.jpg, cat.jpg, mountain.jpg, etc.
- Install Redis as in the previous example.
- Also, create a server.js file right away and add all the required dependencies:
npm install express cors body-parser morgan @redis/client uuid
- express — used to create the server and routes (for example, to handle /get-captcha and /verify-captcha requests).
- cors — middleware to enable cross-origin requests.
- body-parser — middleware for parsing data sent in /verify-captcha requests.
- morgan — middleware used with the dev logging level to display brief request information.
- @redis/client — Redis client for storing captcha data and verifying whether the user has passed the captcha.
- uuid — library for generating unique IDs for captchas and users to track their sessions and related data.
- Create an index.html file and add the structure and the full outer shell of our future captcha:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Grid Captcha</title>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: #f4f4f4;
margin: 0;
}
.captcha-container {
background: white;
padding: 20px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
text-align: center;
border-radius: 8px;
}
.captcha-images {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-bottom: 15px;
}
.captcha-images img {
width: 100px;
height: 100px;
object-fit: cover;
border: 2px solid #ddd;
cursor: pointer;
border-radius: 5px;
transition: transform 0.3s ease;
}
.captcha-images img.selected {
transform: scale(1.1);
border-color: #007bff;
}
.captcha-images img:hover {
transform: scale(1.05);
}
.submit-button {
padding: 10px 20px;
background: #007bff;
color: white;
border: none;
cursor: pointer;
border-radius: 5px;
}
.submit-button:disabled {
background: #ddd;
cursor: not-allowed;
}
.success-message {
color: green;
display: none;
margin-top: 10px;
}
.error-message {
color: red;
display: none;
margin-top: 10px;
}
</style>
</head>
<body>
<div class="captcha-container">
<p id="captchaInstruction">Loading captcha...</p>
<div class="captcha-images" id="captchaImages"></div>
<button class="submit-button" id="submitButton" disabled>Verify</button>
<p class="success-message" id="successMessage">Captcha passed!</p>
<p class="error-message" id="errorMessage">Incorrect images selected! Please try again.</p>
</div>
<script src="script.js"></script>
</body>
</html>
We will display the captcha as a 3x3 grid with images sized 100x100 pixels. When an image is selected, it enlarges and gets outlined in blue. The "Check" button is initially disabled but becomes active after images are selected; blue borders and white text highlight it. Messages about successful or failed verification are shown in green or red.
- In a new file script.js, we will create several variables and fetch the captcha from the server using the fetchCaptcha function:
let selectedImages = new Set();
let captchaId = null;
let userId = null;
document.addEventListener("DOMContentLoaded", async () => {
userId = localStorage.getItem("userId");
if (!userId) {
userId = crypto.randomUUID();
localStorage.setItem("userId", userId);
}
await fetchCaptcha();
});
- selectedImages is a collection that will store the selected images.
- captchaId is the current captcha ID used for verification.
- userId is the user identifier stored in localStorage. Cookies could also be used, but in this case, it’s not necessary since userId is used only on the client side and is not sent with every server request (unlike during authentication). If there is no userId, a new one is generated using crypto.randomUUID() and saved to localStorage.
6. Next, we load the captcha from the server:
async function fetchCaptcha() {
try {
const response = await fetch("http://localhost:3000/get-captcha", {
headers: { "x-user-id": userId },
});
const data = await response.json();
if (data.success) {
document.getElementById("captchaInstruction").textContent =
"Captcha already passed!";
document.getElementById("captchaImages").innerHTML = "";
document.getElementById("submitButton").style.display = "none";
return;
}
captchaId = data.captchaId;
document.getElementById(
"captchaInstruction"
).textContent = `Select all images with ${data.category}`;
renderImages(data.images);
} catch (error) {
console.error("Captcha loading error:", error);
alert("An error occurred. Please try again.");
}
}
The fetchCaptcha function makes a request to the server to get the captcha. The request includes a header with the userId.
The server returns a JSON object containing information about whether the user has already passed the captcha (data.success).
If the captcha is already passed, a message is displayed on the page, and the images and button are hidden.
If the captcha is not passed, the captchaId and image category are saved, then the renderImages function is called to display the selection images.
function renderImages(images) {
const container = document.getElementById("captchaImages");
container.innerHTML = "";
selectedImages.clear();
document.getElementById("submitButton").disabled = true;
images.forEach((imgData, index) => {
const img = document.createElement("img");
img.src = imgData.src;
img.dataset.index = index;
img.addEventListener("click", () => toggleImageSelection(img, imgData.src));
container.appendChild(img);
});
}
function toggleImageSelection(img, src) {
if (selectedImages.has(src)) {
selectedImages.delete(src);
img.classList.remove("selected");
} else {
selectedImages.add(src);
img.classList.add("selected");
}
document.getElementById("submitButton").disabled = selectedImages.size === 0;
}
- renderImages receives an array of images and displays them inside the container with the ID captchaImages.
- For each image, an <img> element is created with the src and data-index attributes assigned. Then a click event handler is added to the image.
- When an image is selected, it is added to the selectedImages collection. While nothing is selected, the submit button (submitButton) remains disabled.
- When the user clicks on an image, it is either added to or removed from the selectedImages collection.
- Visually, this is reflected by toggling the selected class, which enlarges the image and changes its border to blue.
- The submit button is enabled only if at least one image is selected.
8. Next, we send the selected images to the server:
document.getElementById("submitButton").addEventListener("click", async () => {
try {
const response = await fetch("http://localhost:3000/verify-captcha", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-user-id": userId,
},
body: JSON.stringify({
captchaId,
selectedImages: Array.from(selectedImages),
}),
});
const result = await response.json();
document.getElementById("successMessage").style.display = result.success
? "block"
: "none";
document.getElementById("errorMessage").style.display = result.success
? "none"
: "block";
setTimeout(fetchCaptcha, 1500);
} catch (error) {
console.error("Error submitting captcha:", error);
alert("Server error. Please try again later.");
}
});
- When the user clicks the "Verify" button, a POST request is sent to the server at the /verify-captcha endpoint, passing the captchaId and an array of selected images.
- Based on the server's response (a JSON object with a success field), we display either a success or an error message.
- After the captcha check, the setTimeout function calls fetchCaptcha to refresh the captcha after 1.5 seconds.
9. Let’s move to the previously created server.js file and import the dependencies we already installed:
import express from "express";
import cors from "cors";
import bodyParser from "body-parser";
import morgan from "morgan";
import { createClient } from "@redis/client";
import { v4 as uuidv4 } from "uuid";
10. Connecting to Redis:
const app = express();
const PORT = 3000;
const redisClient = createClient();
redisClient.connect().catch((err) => console.error("Redis error:", err));
Here:
- An instance of the Express application (app) is created.
- The port the server will run on is set (3000).
- A Redis client is created and a connection is established.
11. Middleware setup:
app.use(cors({ origin: true, credentials: true }));
app.use(bodyParser.json());
app.use(morgan("dev"));
- cors: allows access from any origin and supports sending cookies.
- bodyParser: parses JSON request bodies.
- morgan: logs requests to the console using the "dev" format.
12. Serve static images from the images folder under the /images URL path:
app.use("/images", express.static("images"));
13. Define the image array:
Each object contains the image path and its category — in the future, metadata can be expanded and images can be added or retrieved from a database.
const images = [
{ src: "images/car1.jpg", category: "vehicle" },
{ src: "images/car2.jpg", category: "vehicle" },
{ src: "images/car3.jpg", category: "vehicle" },
{ src: "images/car4.jpg", category: "vehicle" },
{ src: "images/ship.jpg", category: "vehicle" },
{ src: "images/plain.jpg", category: "vehicle" },
{ src: "images/cat.jpg", category: "animal" },
{ src: "images/dog1.jpg", category: "animal" },
{ src: "images/dog2.jpg", category: "animal" },
{ src: "images/parrot.jpg", category: "animal" },
{ src: "images/tree.jpg", category: "nature" },
{ src: "images/flower.jpg", category: "nature" },
{ src: "images/mountain.jpg", category: "nature" },
{ src: "images/river.jpg", category: "nature" },
];
14. Randomly select a specified number of images from the array:
function getRandomItems(arr, count) {
return [...arr].sort(() => 0.5 - Math.random()).slice(0, count);
}
15. Add a route handler for fetching the captcha:
app.get("/get-captcha", async (req, res) => {
const userId = req.header("x-user-id");
if (!userId)
return res.status(400).json({ success: false, message: "No userId" });
try {
const passed = await redisClient.get(`captcha_${userId}`);
if (passed)
return res.json({ success: true, message: "Captcha already passed" });
const categories = ["vehicle", "animal", "nature"];
const category = categories[Math.floor(Math.random() * categories.length)];
const selected = getRandomItems(images, 9);
const correct = selected
.filter((img) => img.category === category)
.map((img) => img.src);
const captchaId = uuidv4();
await redisClient.setEx(
`captcha_data_${captchaId}`,
300,
JSON.stringify({ correct })
);
res.json({
success: false,
images: selected,
category,
captchaId,
});
} catch (err) {
console.error("Error in /get-captcha:", err);
res.status(500).json({ success: false, message: "Server error" });
}
});
What this part of the code does:
- It checks for the presence of a userId in the request headers. If it's missing, an error is returned.
- It verifies whether the user has already passed the captcha. If so, a message is sent indicating that.
- A category (vehicles, animals, or nature) is randomly selected.
- Nine random images are generated, from which the correct ones (matching the selected category) are identified.
- Information about the correct images is saved in Redis with a 5-minute expiration (300 seconds).
- An object containing the images, the selected category, and a unique captcha ID is sent in the response.
16. Next, let's add a route handler for captcha verification:
app.post("/verify-captcha", async (req, res) => {
const userId = req.header("x-user-id");
const { captchaId, selectedImages } = req.body;
if (!userId || !captchaId || !Array.isArray(selectedImages)) {
return res.status(400).json({ success: false, message: "Invalid data" });
}
try {
const raw = await redisClient.get(`captcha_data_${captchaId}`);
if (!raw)
return res.status(400).json({ success: false, message: "Captcha expired" });
const { correct } = JSON.parse(raw);
const isValid =
selectedImages.length === correct.length &&
selectedImages.every((img) => correct.includes(img));
if (isValid) {
await redisClient.setEx(`captcha_${userId}`, 3600, "passed");
}
res.json({
success: isValid,
message: isValid ? "Captcha passed!" : "Incorrect images selected!",
});
} catch (err) {
console.error("Error in /verify-captcha:", err);
res.status(500).json({ success: false, message: "Server error" });
}
});
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
- Here we retrieve the userId, captchaId, and the selected images from the request body.
- We check whether the captcha exists and hasn't expired.
- Then we compare the selected images with the correct ones.
- If the captcha is solved correctly, we save the success status in Redis for 1 hour (3600 seconds).
- Finally, we send the verification result: either a successful pass or an error.
17. And finally, start the redis-server and run server.js with the command node server.js, then open the project in your browser at http://localhost:3000. Time to test it!

We’ve created a fully functional CAPTCHA with image selection based on a specific category!
After successfully solving the CAPTCHA and refreshing the page, we’ll see the following message instead of the CAPTCHA:

Our “site” preserves the CAPTCHA state by remembering the userId and storing this data in memory for 1 hour. After that, the userId is reset, and the user will need to solve the CAPTCHA again.
The created CAPTCHAs shouldn’t remain static—there’s plenty of room for growth and added complexity. Here are some ideas for how to improve and extend your system:
- Add more images! This will increase the complexity and diversity of the CAPTCHA.
- Shuffle image coordinates (not just the src). To prevent brute-force attacks using image URLs, you can return images in base64 format or use hashes instead of direct src links.
- Validate IP address / user-agent / fingerprint: adds a basic level of protection against automated CAPTCHA attempts using different userIds.
- Timeouts between attempts to slow down repeated guessing.
- Issue a one-time captchaToken instead of captcha_${userId}, which can then be used during login or other secure actions.
- Add a signature (HMAC) to the captchaId to prevent tampering.
- Add “trap” images—visually similar but incorrect choices to increase the challenge.
- Docker containerization for more stable deployment.
- HTTPS-only + SameSite cookies to protect tokens—especially important if you later integrate authentication.
- Logging of suspicious activity, such as repeated errors or unusually fast responses.
- Language switching capability (e.g., Russian/English) for better accessibility.
- Include additional scripts or hidden elements to further obfuscate CAPTCHA behavior from bots.
Additional Cookies You Can Add:

Creating your own CAPTCHA is much more than just protecting a form! It’s an exercise that brings together layout design, client-side logic, server-side development, and data storage. It covers everything—from UI to architectural decisions. The result is a compact but rich project, especially valuable for those looking to improve both frontend and backend skills at the same time. And of course, it’s a great way to add something unique and useful to your portfolio.
We hope this guide was helpful—wishing you smooth learning and successful projects!
Note: We'd like to remind you that the product is used to automate testing on your own websites and on websites to which you have legal access.