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.

  • Walk through the original 30-line snippet line-by-line so you can see exactly what every import and call does.
  • Show you my extended, production-ready version: command-line switches, automatic verification, custom fonts/colours/noise, in-memory handling, and batch mode everything I wished the demo had on day one.
  • Share next-step ideas (FastAPI micro-service, audio CAPTCHAs, adversarial OCR tests) and wrap up with a few lessons learned.

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:

  • Flags so I can whip up 5 CAPTCHAs in one go (--count 5).
  • Verification loop when I’m demoing to friends (--verify).
  • Memory-only mode for server responses no temp files (--nodisk).
  • House-keeping so PNGs older than n minutes vanish automatically.
  • Colour / font / noise knobs because brand guidelines are real.

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

  • FastAPI endpoint/captcha/new returns base64 PNG + token, /captcha/verify checks answer and TTL.
  • Redis store – token → answer → expiry, so I never keep secrets in RAM longer than necessary.
  • Audio fallbackcaptcha.audio.AudioCaptcha for accessibility compliance.
  • Adversarial OCR tests – pipe PNGs into Tesseract, tighten noise until solve-rate < 1 %.
  • Vue widget – tiny component that fetches fresh images with fetch() and updates view in-place.

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.

Related blog posts