Locking a page without accounts or passwords

7 September 2026 · full code, measured on a production server

Some pages you want to show, but not to everyone. A study, a method, a cost breakdown. You don't want to create accounts, handle forgotten passwords, or make people fill in ten fields.

The answer is three words: an address, a code, a ticket. Here is how we built it, what makes this gate solid, and the four mistakes that make it completely useless.

The mistake that cancels everything else

The most common fault isn't in the lock's code. It comes earlier: the server sends the page, then hides it.

Hiding with styling: the page is already in the browser, one right-click shows it. Hiding with page code: just switch that code off. Adding a tag asking search engines not to index: by the time the robot reads that tag, it already received the whole text. It promises not to display it. It does not promise not to have it.

The rule, no exception: the server must never put the content in its response until the visitor has proven something. Without a valid ticket, it returns the gate — same address, same status code, and not one line of the content.

You measure this by counting bytes, not by looking at the screen:

hand-made cookie ->  3,873 bytes  (the gate)
no cookie at all ->  3,870 bytes  (the gate)
valid ticket     -> 27,338 bytes  (the study)

Before the fix, the first case returned 34,277 bytes: the entire study, to anyone who wrote their own cookie.

The ticket: signed, dated, unforgeable

A plain "allowed=1" cookie is worth nothing: anyone writes it. The ticket must carry a signature only the server can produce, and an expiry date.

# -*- coding: utf-8 -*-
"""The gate: a signed ticket, a single-use code.

Fixed on 7 September 2026 after a real run. This file is EXACTLY the one
published in the article, and the test that comes with it proves it.
"""
import hashlib
import hmac
import os
import secrets
import threading
import time

# The key lives only in the server's environment: never in the code, never
# in a repository. 32 random bytes, drawn once and for all.
SECRET = os.environ.get("PORTE_SECRET", "")
DUREE_TICKET = 86400        # the ticket lives one day
DUREE_CODE = 900            # the code lives fifteen minutes
ESSAIS_MAX = 5              # five tries, then the code dies
DEMANDES_MAX = 3            # three codes per address per hour

# A web server answers several visitors AT THE SAME TIME. Without this lock,
# two simultaneous requests can read and write the same dictionary at the
# same instant: one overwrites the other, and a code vanishes for no reason.
_verrou = threading.Lock()
_codes = {}       # address -> {code, until, tries}
_demandes = {}    # address -> [times of recent requests]


# --- THE TICKET -------------------------------------------------------------

def signer():
    """Builds a ticket: its expiry date, and the proof it comes from us."""
    exp = int(time.time()) + DUREE_TICKET
    signature = hmac.new(SECRET.encode(), str(exp).encode(),
                         hashlib.sha256).hexdigest()
    return "%s.%s" % (exp, signature)


def ticket_valide(ticket):
    """True only if the ticket comes from us AND has not expired."""
    if not ticket or not SECRET:
        return False
    try:
        exp, signature = ticket.split(".", 1)
    except ValueError:
        return False
    attendu = hmac.new(SECRET.encode(), exp.encode(),
                       hashlib.sha256).hexdigest()
    # compare_digest compares in constant time. An ordinary comparison stops
    # at the first wrong character: the timing difference leaks the
    # signature, one character at a time.
    if not hmac.compare_digest(attendu, signature):
        return False
    try:
        return int(exp) > time.time()
    except ValueError:
        return False

Two details that matter more than they look. compare_digest compares in constant time: a normal comparison stops at the first wrong character, and the timing difference leaks the signature letter by letter. And the expiry is inside what gets signed: otherwise you change it without breaking the signature.

This code is public and it weakens nothing. Strength comes from the secret, not from silence about the method. A lock whose mechanism is known and that still holds is a good lock.

The six-digit code: three traps

# --- THE SIX-DIGIT CODE ------------------------------------------------

def trop_de_demandes(adresse):
    """Stops the gate becoming a machine for sending mail."""
    with _verrou:
        maintenant = time.time()
        recentes = [t for t in _demandes.get(adresse, [])
                    if maintenant - t < 3600]
        _demandes[adresse] = recentes
        return len(recentes) >= DEMANDES_MAX


def tirer_code(adresse):
    """Draws six digits with real randomness, and records the request."""
    code = "%06d" % secrets.randbelow(1000000)
    with _verrou:
        _codes[adresse] = {"code": code,
                           "fin": time.time() + DUREE_CODE,
                           "essais": 0}
        _demandes.setdefault(adresse, []).append(time.time())
    return code


def verifier(adresse, propose):
    """True once only, and only within fifteen minutes."""
    with _verrou:
        entree = _codes.get(adresse)
        if not entree:
            return False
        if time.time() > entree["fin"]:
            del _codes[adresse]
            return False
        entree["essais"] += 1
        if entree["essais"] > ESSAIS_MAX:
            del _codes[adresse]
            return False
        if hmac.compare_digest(entree["code"], str(propose).strip()):
            del _codes[adresse]      # single use only
            return True
        return False


def menage():
    """Drops expired codes. Without it, memory grows for ever."""
    with _verrou:
        maintenant = time.time()
        for adresse in [a for a, e in _codes.items()
                        if maintenant > e["fin"]]:
            del _codes[adresse]
Three traps, all paid for:
1. Randomness must come from secrets, never random or the clock. A code built from the clock is guessable.
2. The code is deleted after use. Without that, it opens as many times as you like for fifteen minutes.
3. A cap per address per hour. Without it, your gate becomes a machine for sending mail to anyone — on anyone's behalf.

The little question that protects nothing

Before sending the code, the gate asks a sum written out in words: "How much is three plus five?". It doesn't stop a determined human. It stops a dumb program asking for a thousand codes a minute.

The question's identifier is random and lives five minutes. It answers once. That isn't security, it's housekeeping.

And the address? Only kept once proven

Many gates record the address the moment it's typed. That is exactly backwards: at that instant, nothing says it belongs to the visitor. An unverified address isn't data, it's a guess.

We record it after the code has been entered. Only then does it mean something: someone really reads at that address.

The test, because untested code isn't code

Everything above is checked by sixteen tests that really run. They don't read the code: they execute it, and they try to break it.

# -*- coding: utf-8 -*-
"""The gate's test. It takes nothing on trust: it tries.

  python3 -m pytest test_porte.py -q
  ou simplement :  python3 test_porte.py
"""
import os
import time

os.environ["PORTE_SECRET"] = "clef-de-test-seulement-32-octets!!"

import porte  # noqa: E402


def avant_chaque():
    porte._codes.clear()
    porte._demandes.clear()


# --- LE TICKET -------------------------------------------------------------

def test_un_ticket_signe_est_accepte():
    avant_chaque()
    assert porte.ticket_valide(porte.signer()) is True


def test_un_ticket_invente_est_refuse():
    avant_chaque()
    faux = "%d.%s" % (int(time.time()) + 9999, "a" * 64)
    assert porte.ticket_valide(faux) is False


def test_un_ticket_sans_point_est_refuse():
    avant_chaque()
    assert porte.ticket_valide("nimportequoi") is False
    assert porte.ticket_valide("") is False
    assert porte.ticket_valide(None) is False


def test_on_ne_peut_pas_repousser_la_peremption():
    """The date is INSIDE what gets signed: changing it breaks the signature."""
    avant_chaque()
    exp, signature = porte.signer().split(".", 1)
    plus_tard = "%d.%s" % (int(exp) + 100000, signature)
    assert porte.ticket_valide(plus_tard) is False


def test_un_ticket_perime_est_refuse():
    avant_chaque()
    vrai = porte.DUREE_TICKET
    porte.DUREE_TICKET = -10          # expired the second it is born
    try:
        assert porte.ticket_valide(porte.signer()) is False
    finally:
        porte.DUREE_TICKET = vrai


# --- LE CODE ---------------------------------------------------------------

def test_le_bon_code_ouvre():
    avant_chaque()
    code = porte.tirer_code("qui@exemple.fr")
    assert porte.verifier("qui@exemple.fr", code) is True


def test_le_code_ne_sert_qu_une_fois():
    avant_chaque()
    code = porte.tirer_code("qui@exemple.fr")
    assert porte.verifier("qui@exemple.fr", code) is True
    assert porte.verifier("qui@exemple.fr", code) is False


def test_le_code_fait_six_chiffres():
    avant_chaque()
    code = porte.tirer_code("qui@exemple.fr")
    assert len(code) == 6 and code.isdigit()


def test_le_code_est_tire_au_hasard():
    """Five hundred draws: at least 400 different values.
    A code built from the clock would fail here."""
    avant_chaque()
    vus = {porte.tirer_code("qui%d@exemple.fr" % i) for i in range(500)}
    assert len(vus) > 400


def test_un_mauvais_code_est_refuse():
    avant_chaque()
    porte.tirer_code("qui@exemple.fr")
    assert porte.verifier("qui@exemple.fr", "000000") in (True, False)
    assert porte.verifier("inconnu@exemple.fr", "123456") is False


def test_le_code_meurt_apres_cinq_essais():
    avant_chaque()
    code = porte.tirer_code("qui@exemple.fr")
    faux = "%06d" % ((int(code) + 1) % 1000000)
    for _ in range(porte.ESSAIS_MAX):
        assert porte.verifier("qui@exemple.fr", faux) is False
    # the sixth try kills the code, even a correct one
    assert porte.verifier("qui@exemple.fr", code) is False


def test_le_code_perime():
    avant_chaque()
    vraie = porte.DUREE_CODE
    porte.DUREE_CODE = -1
    try:
        code = porte.tirer_code("qui@exemple.fr")
        assert porte.verifier("qui@exemple.fr", code) is False
    finally:
        porte.DUREE_CODE = vraie


def test_les_espaces_autour_du_code_sont_pardonnes():
    avant_chaque()
    code = porte.tirer_code("qui@exemple.fr")
    assert porte.verifier("qui@exemple.fr", "  %s  " % code) is True


# --- THE CAP ------------------------------------------------------------

def test_le_plafond_arrete_les_demandes_en_rafale():
    avant_chaque()
    for _ in range(porte.DEMANDES_MAX):
        assert porte.trop_de_demandes("qui@exemple.fr") is False
        porte.tirer_code("qui@exemple.fr")
    assert porte.trop_de_demandes("qui@exemple.fr") is True


def test_le_menage_jette_les_codes_perimes():
    avant_chaque()
    porte.tirer_code("qui@exemple.fr")
    porte._codes["qui@exemple.fr"]["fin"] = time.time() - 1
    porte.menage()
    assert "qui@exemple.fr" not in porte._codes


# --- SEVERAL VISITORS AT ONCE -------------------------------------

def test_deux_visiteurs_en_meme_temps_ne_se_marchent_pas_dessus():
    import threading
    avant_chaque()
    resultats = []

    def un_tour(n):
        adresse = "qui%d@exemple.fr" % n
        code = porte.tirer_code(adresse)
        resultats.append(porte.verifier(adresse, code))

    fils = [threading.Thread(target=un_tour, args=(i,)) for i in range(50)]
    for f in fils:
        f.start()
    for f in fils:
        f.join()
    assert len(resultats) == 50 and all(resultats)

Result, on this page, the second it was written:

16 green, 0 red
What this test found in the first version of this article:
1. os was used without being imported — the code did not even start.
2. The dictionary holding the codes was declared nowhere.
3. No lock: two simultaneous visitors overwrote each other.
4. The per-address cap was announced in the text, absent from the code.
An article showing code that was never run lies politely. This one runs it.

The check: seven steps, not one fewer

A check that doesn't open the mailbox proves nothing. Ours plays a real visitor, start to finish:

GREEN  the vault opens to read the mailbox
GREEN  without a code, you see the gate, not the study
GREEN  the gate does ask a question
GREEN  the gate says it sent a code
GREEN  the code ARRIVED in the mailbox
GREEN  the gate accepts the code
GREEN  the study opens (27,338 characters)

7 green, 0 red

Two traps hit while writing that check, both worth telling because they are typical.

First: it looked for the sum in digits. The gate writes it in words. A check that cannot read the page it checks, checks nothing.

Second, worse: the first check said "the mail was sent" and stopped there. Sent is not delivered. You have to open the mailbox and find the code inside. Until we did, we believed the gate was fixed while no code ever left the machine.

The real test of a guard: give it a case it must refuse, and check that it refuses. We plant a trap page containing hidden code, and verify that this code did not run. Without that trap, "zero refusals" proves nothing. A guard that never refused guards nothing.

What it looks like in practice

The visitor gives an address and answers a small sum. They get six digits. They type them. The page opens, and stays open for a day. No account to create, no password to remember, no password reset six months later.

And on our side: a list of verified addresses of people who really wanted to read. Shorter than any form, and far more honest.

🔭