pyotp TOTP and HOTP codes tested at frozen time: working versions (tested 2026-10-16)
Generate RFC-compatible TOTP and HOTP codes, build otpauth provisioning URIs and tolerate one-step clock drift, all pinned to deterministic time with freezegun.
passed6 passed · 0 failed2026-10-16 21:57 UTC- Status
- passed
- Last passed
- 2026-10-16 21:57 UTC
- Tests
- 6 / 6
- Language
- Python
- Runtime
- Python 3.13.16
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38972656245
The code
Exactly the files that passed. Your agent can pull them with get_recipe("pyotp-totp-freezegun") (install), or see them on GitHub.
pyotp==2.10.0 freezegun==1.5.5 pytest==9.1.1
import datetime
import pyotp
# RFC 4226/6238 test secret: base32 of the ASCII bytes of 12345678901234567890
SECRET = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
def make_totp(
secret: str = SECRET,
issuer: str = "ProvenPin",
account: str = "[email protected]",
) -> pyotp.TOTP:
return pyotp.TOTP(secret, issuer=issuer, name=account)
def make_hotp(secret: str = SECRET) -> pyotp.HOTP:
return pyotp.HOTP(secret)
def instant(timestamp: int) -> datetime.datetime:
# TOTP.now() feeds datetime.now() through time.mktime (local time), while
# freeze_time() pins the local wall clock. Building the instant with
# fromtimestamp() makes the frozen Unix timestamp match on any host TZ.
return datetime.datetime.fromtimestamp(timestamp)
import time
from urllib.parse import parse_qs, unquote, urlparse
import pytest
from freezegun import freeze_time
from otp import SECRET, instant, make_hotp, make_totp
def test_totp_matches_rfc6238_vectors():
otp = make_totp()
assert otp.at(59) == "287082"
assert otp.at(1111111109) == "081804"
assert otp.at(1234567890) == "005924"
@freeze_time(instant(1111111109))
def test_frozen_now_matches_rfc6238_vector():
assert make_totp().now() == "081804"
def test_frozen_now_is_timezone_independent(monkeypatch):
if not hasattr(time, "tzset"):
pytest.skip("tzset is POSIX only")
monkeypatch.setenv("TZ", "Asia/Kolkata")
time.tzset()
try:
with freeze_time(instant(59)):
assert make_totp().now() == "287082"
finally:
monkeypatch.undo()
time.tzset()
def test_valid_window_accepts_one_step_of_drift():
otp = make_totp()
code = otp.at(1111111109)
with freeze_time(instant(1111111109 + 30)):
assert otp.verify(code, valid_window=1) is True
assert otp.verify(code, valid_window=0) is False
def test_hotp_counter_advances_rfc4226_vectors():
hotp = make_hotp()
assert hotp.at(0) == "755224"
assert hotp.verify("287082", 1) is True
assert hotp.verify("755224", 1) is False
def test_provisioning_uri_encodes_issuer_and_account():
uri = make_totp(issuer="ProvenPin", account="[email protected]").provisioning_uri()
parsed = urlparse(uri)
query = parse_qs(parsed.query)
assert parsed.scheme == "otpauth" and parsed.netloc == "totp"
assert query["secret"] == [SECRET]
assert query["issuer"] == ["ProvenPin"]
assert unquote(parsed.path.lstrip("/")) == "ProvenPin:[email protected]"
[pytest] pythonpath = .
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
Click CLI for RRULE next run times, tested with freezegun and CliRunner
A Click command that computes the next run times of an RFC 5545 rule in any IANA zone with dateutil rrule and zoneinfo, with DST gaps normalized and time frozen with freezegun in tests.
itsdangerous URL-safe timed tokens
Sign URL-safe, expiring email-confirmation and password-reset tokens with salts and max_age, testing expiry and salt separation offline with freezegun.