Locking a page without accounts or passwords
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.
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]
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
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.
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.