Complete Guide to Building CAPTCHA for Web Security with Node.js

M. H. NahibM. H. Nahib
Jun 28, 2024·
8 min read

Introduction

In today's digital age, securing web applications is more important than ever. Implementing CAPTCHA is a simple yet effective way to prevent automated abuse. This blog post will guide you through creating a CAPTCHA generation tool using Node.js and the Canvas library, demonstrating how easy it is to add this security feature to your web applications.

Lets Go Reaction GIF by ProBit Global

Initial Setup

Prerequisites

First, ensure that you have Node.js installed on your system. This guide uses Node.js version 20.11.1, but it's always best to use the latest version available.

Project Initialization

Start by creating a new project folder. You can name it anything you like, but for this tutorial, we'll call it easy-captcha. You can create this folder manually or use the terminal with the following commands:

mkdir easy-captcha
cd easy-captcha

Setting Up the Project

To manage project dependencies, initialize a package.json file using:

npm init -y

Next, install TypeScript as a development dependency and create a tsconfig.json file:

npm install --save-dev typescript
npx tsc --init

In your tsconfig.json, update the configuration to set the output directory:

"outDir": "./dist"

Package Installation

Install the necessary packages with the following commands:

For dependencies:

npm install canvas express http-status-codes

For development dependencies:

npm install --save-dev @types/express @types/node ts-node

Here are the versions of the packages used in this project, though you can use the latest versions available:

"devDependencies": {
  "@types/express": "^4.17.21",
  "@types/node": "^20.14.9",
  "ts-node": "^10.9.2",
  "typescript": "^5.5.2"
},
"dependencies": {
  "canvas": "^2.11.2",
  "express": "^4.19.2",
  "http-status-codes": "^2.3.0"
}

Project Structure

Organize your project as follows:

📦app
 ┣ 📂src
 ┃ ┣ 📂controllers
 ┃ ┃ ┗ 📜index.ts
 ┃ ┣ 📂middleware
 ┃ ┃ ┗ 📜index.ts
 ┃ ┣ 📂routes
 ┃ ┃ ┗ 📜index.ts
 ┃ ┗ 📂utils
 ┃ ┃ ┣ 📜canvas.generate.utils.ts
 ┃ ┃ ┣ 📜index.ts
 ┃ ┃ ┣ 📜randomText.generate.ts
 ┃ ┃ ┗ 📜response.utils.ts
 ┗ 📜index.ts

Development

We'll start by creating some utility functions. Here's a brief overview of what each file will contain:

  • canvas.generate.utils.ts: Functions for generating CAPTCHA images using Canvas.

  • randomText.generate.ts: Functions for generating random text for CAPTCHA.

  • response.utils.ts: Utility functions for sending responses.

  • index.ts: Entry point of the application.

  • controllers/index.ts, middleware/index.ts, and routes/index.ts: Placeholder files for organizing controllers, middleware, and routes.

Response Utility

The response.utils.ts file contains a utility function for sending standardized JSON responses in an Express.js application. This utility ensures that your responses are consistent and properly formatted, making it easier to handle client-server communication.

import { getReasonPhrase } from "http-status-codes";
import { Response } from "express";

const response = (
  res: Response,
  code: number,
  status: boolean,
  data: object | null,
  mesage: string
) => {
  if (!mesage) mesage = getReasonPhrase(code);
  return res.status(code).json({
    status,
    data,
    mesage,
  });
};

export default response;

Parameters

  • res (Response): The Express.js response object is used to send the HTTP response.

  • code (number): The HTTP status code for the response (e.g., 200 for OK, 404 for Not Found).

  • status (boolean): A boolean value indicating the success (true) or failure (false) of the request.

  • data (object | null): The data to be included in the response body. This can be any JavaScript object or null if no data is sent.

  • mesage (string): A custom message for the response. If no message is provided, it defaults to the reason phrase for the given status code.

Random Text Generation Utility

The randomText.generate.ts file contains a function to generate random text strings based on specified criteria. This utility can be used for creating random strings for CAPTCHAs or other purposes.

export const generateRandomText = (
  allowNumber: boolean,
  length: number,
  uppercase: boolean
) => {
  let characters = "abcdefghijklmnopqrstuvwxyz";
  if (allowNumber) characters += "0123456789";
  if (uppercase) characters += "ABCDEFGHIJKLMNOPQRSTUVWXYZ";

  let result = "";
  const charactersLength = characters.length;
  for (let i = 0; i < length; i++) {
    result += characters.charAt(Math.floor(Math.random() * charactersLength));
  }

  return result;
};

Parameters

  • allowNumber (boolean): If true, the generated string can include numbers.

  • length (number): The desired length of the generated string.

  • uppercase (boolean): If true, the generated string can include uppercase letters.

The function starts with a base set of lowercase alphabetic characters: "abcdefghijklmnopqrstuvwxyz". If allowNumber is true, it appends numeric characters ("0123456789") to the character set. If uppercase is true, it appends uppercase alphabetic characters ("ABCDEFGHIJKLMNOPQRSTUVWXYZ") to the character set.

CAPTCHA Generation Utility

The canvas.generate.utils.ts file contains functions for generating CAPTCHA images using the Canvas library.

const { createCanvas, registerFont } = require("canvas");

const generateCoreCaptcha = (text: string) => {
  const canvas = createCanvas(200, 100);
  const ctx = canvas.getContext("2d");

  // Set background color
  ctx.fillStyle = "#f0f0f0";
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  // Set text properties
  ctx.font = "30px Arial";
  ctx.fillStyle = "#000";

  // Rotate and draw each character individually
  const xStart = 20;
  const yStart = 50;
  for (let i = 0; i < text.length; i++) {
    const char = text.charAt(i);
    ctx.save();
    const x = xStart + i * 30;
    const y = yStart + (Math.random() * 10 - 5);
    ctx.translate(x, y);
    ctx.rotate(Math.random() * 0.4 - 0.2); // Rotate between -0.2 and 0.2 radians
    ctx.fillText(char, 0, 0);
    ctx.restore();
  }

  // Add noise lines
  for (let i = 0; i < 8; i++) {
    ctx.strokeStyle = "#" + ((Math.random() * 0xffffff) << 0).toString(16);
    ctx.beginPath();
    ctx.moveTo(Math.random() * canvas.width, Math.random() * canvas.height);
    ctx.lineTo(Math.random() * canvas.width, Math.random() * canvas.height);
    ctx.stroke();
  }

  // Add random dots
  for (let i = 0; i < 100; i++) {
    ctx.fillStyle = "#" + ((Math.random() * 0xffffff) << 0).toString(16);
    ctx.beginPath();
    ctx.arc(
      Math.random() * canvas.width,
      Math.random() * canvas.height,
      1,
      0,
      Math.PI * 2
    );
    ctx.fill();
  }

  return canvas;
};

export const generateCaptcha = (text: string, convert?: string) => {
  if (convert === "base64") return generateCoreCaptcha(text).toDataURL();

  return generateCoreCaptcha(text);
};

The index.ts file in the utils directory serves as a central export point for all utility functions within the directory. This approach simplifies the import statements in other parts of the application by consolidating all utility exports into a single file.

export * from "./response.utils";
export * from "./canvas.generate.utils";
export * from "./randomText.generate";

Now let's write controller, middleware, and route one after another.

Create controllers under src folder. Let's create index.ts under controllers. The code block will be

import { Request, RequestHandler, Response } from "express";
import { StatusCodes } from "http-status-codes";
import response from "../utils/response.utils";
import { generateCaptcha, generateRandomText } from "../utils";

const homecontroller: RequestHandler = (req: Request, res: Response): void => {
  const { text, type }: { text?: string; type?: string } = req.query;

  if (text && text.length > 5) {
    return response(
      res,
      StatusCodes.BAD_REQUEST,
      false,
      {
        captcha: text,
        image: null,
      },
      "Text is tool long !"
    );
  }

  const randomText = text ?? generateRandomText(true, 5, true);

  const canvas = generateCaptcha(randomText);

  if (type === "base64")
    return response(
      res,
      StatusCodes.ACCEPTED,
      true,
      {
        captcha: randomText,
        image: generateCaptcha(randomText, "base64"),
      },
      "Success !"
    );
  else {
    res.setHeader("Content-Type", "image/png");
    return canvas.createPNGStream().pipe(res);
  }
};

export default homecontroller;

On homecontroller first of all, check the text length if the text is available otherwise return a BAD_REQUEST. if there is given text as req.query then the randomText will be that otherwise, it will generate randomText using the generateRandomTextutils. if the type is base64 then it will return a response as JSON; otherwise, it will return as a stream after generating a captcha using generateCaptcha.

Create middleware under src folder. Let's create index.ts under middleware. The code block will be

import { Request, Response, NextFunction } from "express";

export const notFoundHandler = (
  req: Request,
  res: Response,
  next: NextFunction
): any => {
  return res.status(404).json({
    status: false,
    data: null,
    message: "Not Found",
  });
};

The piece of code will be used to if there is no page available for the link.

Create routes under src folder. Let's create index.ts under routes. The code block will be

import { Router } from "express";
import homecontroller from "../controllers/";

const router: Router = Router();

router.get("/", homecontroller);

export default router;

Finally crate index.ts on src.

import express, { Application } from "express";
import { Server } from "http";

import home from "./src/routes";
import { notFoundHandler } from "./src/middleware";

const app: Application = express();

app.use("/", home);

app.use(notFoundHandler);

const port: number = Number(process.env.PORT || 3000);

const server: Server = app.listen(port, () => console.log(`🚀 on ${port}`));

Import all the routes and middleware and initialize express.js for the server.

To run the project we need to change something on package.json. In the scripts section of the package.json update some code.

 "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "tsc",
    "start": "node ./dist/index.js",
    "dev": "ts-node app/index"
  },

To run the project locally use

npm run dev

To build the project use

npm run build

API Reference

Index

By hitting the root endpoint (/), the API returns a PNG image containing a captcha.

GET /

Example image:

captcha built from easy-captcha

Custom Text

You can provide your text using a query parameter.

GET /?text=<YOUR_TEXT>

Example image:

For example, using c5Uop:

GET /?text=c5Uop

captcha built from easy-captcha

Note: The text cannot be more than 5 characters long. If it exceeds 5 characters, an error response is returned:

{
  "status": false,
  "data": {
    "captcha": "c5Uop55",
    "image": null
  },
  "message": "Text is too long!"
}

Base64 Response

You can get a JSON response with the text and image base64 string using a query parameter:

GET /?type=base64

Example image:

captcha built from easy-captcha

Conclusion

This tutorial walks you through building a CAPTCHA generation tool with Node.js and Canvas, enhancing your web application's security effortlessly. You'll set up a clear project structure, generate CAPTCHA images dynamically, and handle responses effectively. By implementing these steps, you'll bolster your site's defenses against automated attacks, ensuring a safer experience for your users.

Check out the complete project on GitHub.

27
Subscribe to my newsletter

Read articles from M. H. Nahib directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

M. H. Nahib
M. H. Nahib

Hi, I am Nahib. Working as a software engineer at ImpleVista.