Blog / Coding tips

Simple Math Captcha in PHP With Sessions

Cover image for Simple Math Captcha in PHP With Sessions

A simple math captcha in PHP asks the visitor to solve a small sum like "What is 4 + 7?" before the form is accepted. The server picks two random numbers with random_int(), stores only the answer in the session, prints the question next to the form, and on submit compares the visitor's answer with the stored one, then deletes it so it can't be reused. No Google script, no image library, no JavaScript. Here's how I built the one for my portfolio's contact form, with tests for the tricky inputs.

Everything below was tested on PHP 8.4: the functions from the command line, and the full form on PHP's built-in server with curl and a cookie jar, the way a bot would send it.

Why a math captcha instead of reCAPTCHA?

For a small personal site, a text question has real advantages. It loads nothing from a third party, so there's no extra tracking and no slow script. It works with JavaScript turned off. And because it's plain text inside a <label>, screen readers can read it, which is more than many image captchas can say.

The honest limit: it stops automated spam bots that fill in forms blindly. It won't stop a bot written specifically for your site, or a person paid to send spam. That's fine for a portfolio or a small business site, and you can stack it with a honeypot and rate limiting, which I cover at the end.

Step 1: two small functions

<?php
declare(strict_types=1);

/** Make a new sum, store the answer in the session, return the question. */
function captcha_question(): string
{
    $a = random_int(2, 9);
    $b = random_int(1, 9);
    $_SESSION['captcha_answer'] = $a + $b;

    return "What is {$a} + {$b}?";
}

/** Check the visitor's answer. Each question can only be checked once. */
function captcha_passed(mixed $input): bool
{
    $expected = $_SESSION['captcha_answer'] ?? null;
    unset($_SESSION['captcha_answer']); // no second guess on the same sum

    if (!is_int($expected) || !is_string($input)) {
        return false;
    }
    $input = trim($input);

    return ctype_digit($input) && (int) $input === $expected;
}

The important details:

  • The answer lives in the session, never in the page. Some tutorials put the two numbers in hidden form fields and add them up on submit. A bot can read those fields just as easily as it can read the question, so that captcha checks nothing. With sessions, the browser only sees the question.
  • random_int() instead of rand(). random_int() uses a cryptographically secure generator. For numbers from 2 to 9 it hardly matters, but it's a good habit and costs nothing.
  • One check per question. captcha_passed() deletes the answer before comparing. Without that, a bot that solves the sum once could replay the same request a thousand times with the same session cookie.
  • Strict input checks. The answer must be a string made only of digits. ctype_digit() rejects empty strings, decimals, signs, exponents and arrays sent as captcha[]=7.

Step 2: test the tricky inputs

I test logic like this from the command line before touching HTML. The CLI has no real session, so the test creates an empty $_SESSION array:

<?php
require __DIR__ . '/captcha.php';
$_SESSION = []; // the CLI has no session, so fake one

echo captcha_question(), "\n";
$answer = (string) $_SESSION['captcha_answer'];
var_dump(captcha_passed(" $answer "));  // right answer, extra spaces
var_dump(captcha_passed($answer));      // same answer again: already used

foreach (['', '7.0', '+7', '1e1', 'seven', 'Ù§'] as $bad) {
    captcha_question();
    $_SESSION['captcha_answer'] = 7;    // force a known answer
    echo var_export($bad, true), ' => ', var_export(captcha_passed($bad), true), "\n";
}

captcha_question();
var_dump(captcha_passed(['7']));        // someone sends captcha[]=7
var_dump(captcha_passed('7'));          // no question in the session

// Output:
// What is 3 + 9?
// bool(true)
// bool(false)
// '' => false
// '7.0' => false
// '+7' => false
// '1e1' => false
// 'seven' => false
// 'Ù§' => false
// bool(false)
// bool(false)

The correct answer passes even with extra spaces, which matters on phones where keyboards sometimes add one. The second try with the same answer fails, because the first check used it up. Every strange input fails, including the Arabic-Indic digit Ù§ (seven), and a missing question in the session fails too.

Why not just use ==?

It's tempting to write $_POST['captcha'] == $_SESSION['captcha_answer']. Look what PHP's loose comparison and (int) casts accept:

<?php
$expected = 10;
var_dump('1e1' == $expected);   // loose: "1e1" is the number 10
var_dump(' 10' == $expected);   // loose: leading space is allowed
var_dump((int) '10abc' === $expected); // the cast drops "abc"
var_dump(ctype_digit('1e1'), ctype_digit('10abc'));

// Output:
// bool(true)
// bool(true)
// bool(true)
// bool(false)
// bool(false)

In PHP 8, "1e1" == 10 is true, because "1e1" is a numeric string in scientific notation. A cast is no better: (int) "10abc" quietly becomes 10. Neither is a real security hole for a captcha this small, but checking ctype_digit() first and then comparing with === means the code accepts exactly what you meant and nothing else.

Step 3: put it in a form

<?php
declare(strict_types=1);
session_start();
require __DIR__ . '/captcha.php';

function e(string $v): string
{
    return htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

$error = '';
$message = is_string($_POST['message'] ?? null) ? trim($_POST['message']) : '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (!captcha_passed($_POST['captcha'] ?? null)) {
        $error = 'Wrong answer to the spam check. Please try the new sum.';
    } elseif ($message === '') {
        $error = 'Please write a message.';
    } else {
        // Passed: validate the other fields, then save or mail() the message.
        $_SESSION['flash'] = 'Thanks, your message was sent.';
        header('Location: ' . $_SERVER['PHP_SELF'], true, 303);
        exit;
    }
}
$flash = $_SESSION['flash'] ?? '';
unset($_SESSION['flash']);
$question = captcha_question(); // a fresh sum on every render
?>
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Contact</title></head>
<body>
<?php if ($flash !== ''): ?><p><?= e($flash) ?></p><?php endif; ?>
<?php if ($error !== ''): ?><p role="alert"><?= e($error) ?></p><?php endif; ?>
<form method="post">
  <label for="message">Message</label>
  <textarea id="message" name="message" required><?= e($message) ?></textarea>

  <label for="captcha">Spam check: <?= e($question) ?></label>
  <input id="captcha" name="captcha" type="text" inputmode="numeric"
         pattern="[0-9]*" maxlength="2" autocomplete="off" required>

  <button type="submit">Send</button>
</form>
</body>
</html>

A few things to notice:

  • A fresh sum on every render. captcha_question() runs after the POST is handled, so a wrong answer always comes back with a new question. The visitor's message is kept, so they only redo the sum.
  • Phone-friendly input. inputmode="numeric" opens the number keypad on phones, maxlength="2" fits the largest answer (9 + 9 = 18), and autocomplete="off" stops the browser from suggesting an old answer. I use type="text" rather than type="number" because number inputs add spinner arrows and accept things like 1e1. My HTML form input types and validation post explains these attributes.
  • session_start() comes first. It must run before any HTML is printed, or PHP can't send the session cookie and you'll get a "headers already sent" warning.
  • The error uses role="alert", so screen readers announce it after the page reloads.

This form only checks the captcha and that the message isn't empty. A real form also needs proper validation of every field and a CSRF token, as in my PHP form validation and sanitization guide.

What happened when I attacked it with curl

  1. A wrong answer (captcha=99) showed the error, kept the message text, and printed a new sum.
  2. The right answer returned 303 See Other and the thank-you message.
  3. Replaying the same correct answer without loading the form again was rejected, because the answer had been deleted.
  4. A POST with no session cookie, the way many bots send forms, was rejected because there was no stored answer to compare with.

Making it stronger

A math captcha works best as one layer. On my site's contact form it sits next to:

  • A honeypot field that people never see but bots fill in. I showed one in my post on a PHP contact form with mail().
  • A time check. The session records when the form was shown, and a submit that comes back in under two seconds is treated as a bot. People can't read and type that fast.
  • A rate limit on how many messages one IP address can send per hour.

If spam still gets through all of that, it's time for a service like Cloudflare Turnstile or hCaptcha.

FAQ

Is a math captcha secure?

It's secure in the sense that the answer can't be read from the page. It's not strong: a determined attacker can parse "What is 4 + 7?" easily. Treat it as a spam filter for blind bots, not as protection for a login form.

Can I make the question harder for bots?

You can write the numbers as words ("four plus seven") or mix addition and subtraction. Keep it easy for people, though. A captcha that annoys real visitors costs you more messages than it saves.

Does it work without cookies?

No. PHP sessions need the session cookie, so a visitor who blocks all cookies will always fail. That's rare, but say so in the error message if your audience is privacy-focused.

Conclusion

A simple PHP math captcha is two functions: one that makes a sum and stores the answer in the session, and one that checks the answer once with ctype_digit() and ===, then deletes it. Show a new sum on every render, keep the visitor's message, and combine it with a honeypot and a time check. The PHP manual's page on random_int() is worth reading next.

// note

How to read this note.

This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.

If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.

Related notes

Gemini Structured Output With Zod in Next.js
Coding tips

Gemini Structured Output With Zod in Next.js

Describe the JSON once in Zod, send z.toJSONSchema() to Gemini as responseJsonSchema, then validate the reply with safeParse and retry once with the errors.

October 2, 2026 · 9 min read

How to Add a Gemini API Key to Next.js and Vercel
Coding tips

How to Add a Gemini API Key to Next.js and Vercel

Step by step: a Gemini key from Google AI Studio in .env.local, a server-only helper, a Next.js route handler, Vercel environment variables and a leak test.

October 2, 2026 · 8 min read

Build an Offline Habit Tracker With localStorage
Coding tips

Build an Offline Habit Tracker With localStorage

A small offline habit tracker in plain HTML and JavaScript: habits saved as JSON in localStorage, streaks from local dates, a 7-day row and a JSON backup.

October 2, 2026 · 10 min read