Site icon FSIBLOG

How I Built a Flexible CAPTCHA Generator & Verifier Using Python

How I Built a Flexible CAPTCHA Generator & Verifier Using Python

How I Built a Flexible CAPTCHA Generator & Verifier Using Python

I’ve always enjoyed adding little “mini-projects” to my tool belt especially ones that combine clean code with a bit of visual flair. Recently I needed a dead-simple CAPTCHA for a hobby site, so I reached for the underrated captcha Python library and ended up writing a tiny CLI that now lives in my dotfiles.

Deep-dive into the “Hello World” CAPTCHA

Before I rewrote anything, I wanted to know where every byte came from. Here’s the tiniest viable script, followed by a table-style breakdown:

from PIL import Image
import random, string


def generate_captcha_text(length: int = 6) -> str:
chars = string.ascii_letters + string.digits
return ''.join(random.choices(chars, k=length))


def generate_captcha_image(captcha_text: str,
image_width: int = 300) -> str:
image = ImageCaptcha(width=image_width)
file_name = f"{captcha_text}.png"
image.write(captcha_text, file_name)
return file_name


if __name__ == "__main__":
text = generate_captcha_text()
file_path = generate_captcha_image(text)

print(f"Generated CAPTCHA text: {text}")
Image.open(file_path).show()
#CodeWhat really happens
1from captcha.image import ImageCaptchaPulls in the renderer that bends, warps, and sprinkles noise on text simple OCR dies instantly.
2from PIL import ImagePillow lets me open/preview the PNG that ImageCaptcha.write() just saved.
3import random, stringCore helpers: random.choices() (sampling with replacement) and string.ascii_letters + string.digits (62-char pool).
4-9generate_captcha_text()1) Builds the pool. 2) Picks length symbols uniformly. 3) Returns a contiguous string such as A9cV4K.
11-19generate_captcha_image()1) Instantiates the renderer (I can also tweak height/fonts/colours/noise).\ 2) write() rasterises & saves the PNG.
21-31__main__ guardRandom text → image → debug print. Finally Image.open().show() asks my OS to preview the file.

Fast tweaks

What I changedOne-liner
8-char puzzlegenerate_captcha_text(8)
Custom size/fontImageCaptcha(width=280, height=90, fonts=['./Roboto-Bold.ttf'])
Black on white, 40 % noiseImageCaptcha(color='black', background='white', noise_level=0.4)

Level-up: the captcha_tool.py CLI

One-offs are fun, but I wanted a Swiss-Army knife:

Below is the trimmed-down version (full gist at the end). Copy-paste, rename to captcha_tool.py, and you’re off.

\captcha_tool.py
Generate and optionally verify CAPTCHA challenges from the command line.
"""

import argparse, io, random, string, sys
from datetime import datetime, timedelta
from pathlib import Path

from captcha.image import ImageCaptcha
from PIL import Image


# ---------- helpers ---------- #
def random_text(length: int) -> str:
pool = string.ascii_letters + string.digits
return "".join(random.choices(pool, k=length))


def draw_captcha(text: str,
width: int = 300,
height: int = 100,
color: str = "black",
background: str = "white",
noise: float = 0.4,
fonts: list[str] | None = None,
to_disk: bool = True) -> tuple[str, bytes | None]:
gen = ImageCaptcha(width=width, height=height,
fonts=fonts,
color=color, background=background,
noise_level=noise)
if to_disk:
name = f"{datetime.utcnow().timestamp()}_{text}.png"
gen.write(text, name)
return name, None
buf = io.BytesIO()
gen.write(text, buf)
return f"mem://{text}", buf.getvalue()


# ---------- CLI entry-point ---------- #
def main() -> None:
p = argparse.ArgumentParser(description="Quick CAPTCHA generator")
p.add_argument("--len", type=int, default=6, help="char length")
p.add_argument("--count", type=int, default=1, help="# captchas")
p.add_argument("--verify", action="store_true", help="ask user to solve")
p.add_argument("--nodisk", action="store_true", help="keep in memory")
p.add_argument("--clean-after", type=int, default=15,
help="delete PNGs older than X minutes")
args = p.parse_args()

challenges: list[tuple[str, str]] = []
for _ in range(args.count):
ans = random_text(args.len)
fname, _ = draw_captcha(ans, to_disk=not args.nodisk)
print(f"[+] CAPTCHA ready: {fname}")
challenges.append((ans, fname))

if args.verify:
for ans, fname in challenges:
if not args.nodisk:
Image.open(fname).show()
guess = input("Text you see → ").strip()
print("✅ Correct!\n" if guess == ans else f"❌ Nope (was {ans})\n")

if not args.nodisk and args.clean_after > 0:
cut = datetime.utcnow() - timedelta(minutes=args.clean_after)
for png in Path(".").glob("*.png"):
if datetime.utcfromtimestamp(png.stat().st_mtime) < cut:
png.unlink(missing_ok=True)


if __name__ == "__main__":
main()

Quick demos

Skill I practiseCommand I run
CLI flag parsingpython captcha_tool.py --len 8 --count 5
User validationpython captcha_tool.py --verify
Memory-only flowpython captcha_tool.py --nodisk
Auto cleanuppython captcha_tool.py --clean-after 1
Custom stylingEdit draw_captcha() and crank noise / fonts

Where I’ll take it next

Final thought

I went in needing a single throw-away CAPTCHA; I came out with a reusable CLI and a blueprint for a micro-service. The lesson? Even the tiniest scripts are opportunities to practise clean switches, separation of concerns, and automated clean-up. Next time you’re about to toss a “one-liner” into production, pause, add flags, and you might surprise yourself with how far that extra 30 minutes takes you.

Exit mobile version