mrkeyoor.com_
Fri 07 Aug 20:54 UTC
PyPISecurityupdated 07 Aug 2026

pyotp

PyOTP is the server side of the six-digit codes in Google Authenticator, Authy, and 1Password. It implements the two OATH standards: HOTP from RFC 4226, where the code is derived from a shared secret plus a counter, and TOTP from RFC 6238, where the counter is the current Unix time divided by a 30 second interval. You give it a base32 secret and it gives you the current code, a constant-time verify, and the otpauth:// provisioning URI you render as a QR code so a phone can enroll. That is the whole library. It has no runtime dependencies beyond the standard library, and it deliberately stops at the crypto: storing secrets, rejecting reused codes, and rate limiting login attempts are all your job.

Verdict

The right library for the TOTP math in Python: small, typed, dependency-free, and faithful to the RFCs. Just be clear that it is one component of a two-factor feature, not the feature, and read WebAuthn options before you commit new users to shared secrets.

API stability5/5TOTP, HOTP, random_base32, parse_uri, and provisioning_uri have kept the same shapes across the 2.x line, the project states it follows semantic versioning, and the underlying RFCs are not changing.
Docs3/5The README is the real documentation and is unusually good on security requirements, with pyauth.github.io/pyotp adding generated API pages; there is no cookbook for the hard parts like secret storage, replay tables, or recovery codes.
Maintenance3/5Pushed June 2026 with only 3 open issues (4 counting PRs), so nothing is piling up, but the gap between 2.9.0 in July 2023 and 2.10.0 in June 2026 shows how slowly fixes reach PyPI.
Ecosystem4/5Around 9M weekly downloads and 3.3k stars make it the default Python answer for OTP, and its provisioning URIs pair with any QR library; it is a leaf dependency, so there is no plugin ecosystem around it.

Use it if

  • You are adding TOTP two-factor login to a Python service and want an implementation that follows RFC 6238 rather than a hand-rolled HMAC loop you will get subtly wrong
  • You need otpauth:// provisioning URIs so users can enroll by scanning a QR code; provisioning_uri() emits the label, issuer, and any non-default algorithm, digits, or period parameters correctly
  • You want a dependency with no third-party runtime requirements in an authentication path, shipping py.typed for type checking and using hmac.compare_digest under the hood for verification
  • You are writing an integration test or automation client that must log in to a system protected by TOTP, and you hold the shared secret
Skip it if

Setup reality

pip install pyotp and you are done: no compiled extensions, no third-party runtime dependencies, Python 3.8 and up, py.typed included. The friction is entirely in the parts PyOTP does not do. You need a place to store per-user base32 secrets that is at least as protected as password hashes, a table of already-consumed counter values so a code cannot be replayed inside its window, and login throttling. Two API details bite people: TOTP.at() with a naive datetime converts through local time via time.mktime, so passing a naive UTC datetime on a non-UTC server gives wrong codes, and random_base32 raises ValueError for any length under 32 because shorter secrets fall below 160 bits. If you set non-default digits, period, or algorithm, test them against the specific authenticator apps your users have, because not all of them honor those URI parameters.

Patterns

Create a per-user shared secretgenerate-secret

import pyotp

secret = pyotp.random_base32()          # 32 base32 chars, 160 bits
print(secret)                            # 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP'

hex_secret = pyotp.random_hex()          # 40 hex chars, for systems that want hex

random_base32 uses secrets.SystemRandom, not random. It raises ValueError below length 32 on purpose. Store this like a password hash: encrypted or in a restricted column, never in logs, never in a URL you keep.

Generate and check a time-based codeverify-totp

import pyotp

totp = pyotp.TOTP(user.otp_secret)
code = totp.now()                 # what the phone is showing right now

if totp.verify(submitted_code):
    login(user)
else:
    reject()

verify compares with hmac.compare_digest, so it does not leak timing on the digits. It does not remember anything: the same code verifies again a second later unless you store consumed counters yourself.

Accept codes from adjacent time stepsclock-drift-window

totp = pyotp.TOTP(secret)

totp.verify(code)                  # current 30s step only
totp.verify(code, valid_window=1)  # previous, current, and next step

valid_window=1 covers phones with a slightly wrong clock and users who type slowly, at the cost of widening the guess space and the replay window. Anything above 1 is usually a sign your server clock needs NTP instead.

Reject a code that was already usedreplay-protection

import datetime

totp = pyotp.TOTP(user.otp_secret)
now = datetime.datetime.now()
counter = totp.timecode(now)      # the 30-second bucket number

if counter <= user.last_otp_counter:
    reject("code already used")
elif totp.verify(code, for_time=now):
    user.last_otp_counter = counter
    user.save()
    login(user)

PyOTP will never do this for you, and without it a code shoulder-surfed or phished stays valid for the rest of its window. timecode() is the counter used internally, which makes it the natural thing to persist.

Build the otpauth URI for enrollmentprovisioning-uri

import pyotp

uri = pyotp.TOTP(secret).provisioning_uri(
    name="alice@example.com",
    issuer_name="Acme",
)
print(uri)
# otpauth://totp/Acme:alice%40example.com?secret=...&issuer=Acme

issuer_name is what shows as the account group in the authenticator app; leaving it out gives users a list of anonymous entries. Default algorithm, digits, and period are omitted from the URI by design, since they are the defaults every app assumes.

Turn the URI into a scannable QR imagerender-qr-code

import io
import qrcode          # pip install qrcode[pil]

uri = pyotp.TOTP(secret).provisioning_uri(name=email, issuer_name="Acme")

buf = io.BytesIO()
qrcode.make(uri).save(buf, format="PNG")
return Response(buf.getvalue(), mimetype="image/png")

Render the QR server side and never log the URI, because it contains the raw secret. Serve it only over an authenticated session and only during enrollment, then require one valid code before you mark 2FA as active.

Use counter-based codes for SMS or emailhotp-counter

import pyotp

hotp = pyotp.HOTP(secret)
send_sms(user.phone, hotp.at(user.otp_counter))

# later, on submit
if hotp.verify(submitted, user.otp_counter):
    user.otp_counter += 1
    user.save()

HOTP has no clock, so the counter is state you own and must increment on success. Resend without incrementing or the user's phone and your database drift apart; most apps also allow a small look-ahead window on verify.

Change digits, interval, and hashcustom-parameters

import hashlib
import pyotp

totp = pyotp.TOTP(secret, digits=8, interval=60, digest=hashlib.sha256)
print(totp.provisioning_uri(name="alice", issuer_name="Acme"))
# ...&algorithm=SHA256&digits=8&period=60

PyOTP rejects md5 and shake_128 outright and caps digits at 10. Non-default values are legal in the URI spec but not every authenticator app reads them, so verify against the apps your users actually run before shipping.

Read an otpauth URI back into an objectparse-uri

import pyotp

otp = pyotp.parse_uri(
    "otpauth://totp/Acme:alice%40example.com?secret=JBSWY3DPEHPK3PXP&issuer=Acme"
)
print(type(otp).__name__, otp.name, otp.issuer, otp.interval)
# TOTP alice@example.com Acme 30

Useful for importing exported authenticator backups. It raises ValueError on a missing secret, an unknown algorithm, or digits outside 6, 7, and 8, so wrap it when the input came from a user upload.

Show how long the current code laststime-remaining

import datetime

totp = pyotp.TOTP(secret)
remaining = totp.interval - datetime.datetime.now().timestamp() % totp.interval
print(f"{remaining:.0f}s left")

This is the countdown ring authenticator apps draw. Showing it on your verification page cuts support tickets from users who submit a code just as it rolls over.

Generate a code for a chosen momentcode-at-specific-time

import datetime
import pyotp

totp = pyotp.TOTP(secret)
print(totp.at(1234567890))                                   # Unix timestamp
print(totp.at(datetime.datetime.now(datetime.timezone.utc))) # aware datetime
print(totp.at(datetime.datetime.now(), counter_offset=1))    # next step

An aware datetime is converted through UTC; a naive one goes through time.mktime and is therefore interpreted in the server's local timezone. Pass aware datetimes or plain integers and this class of bug disappears.

Generate Steam-style alphanumeric codessteam-guard

import pyotp.contrib

steam = pyotp.contrib.Steam(secret)
print(steam.now())   # '2J8R3', five alphanumeric characters

Steam is in pyotp.contrib because it follows no standard and is provided for reference only. parse_uri also returns a Steam object when the URI carries encoder=steam.

Alternatives

PackageRegistryPick it when
passlibPyPIYou want TOTP plus the operational parts: passlib.totp encrypts secrets with a key wallet and returns a match object carrying the matched counter for replay checks.
django-otpPyPIYou are on Django and want device models, admin screens, and login integration instead of raw code generation.
webauthnPyPIYou can require passkeys or security keys; WebAuthn removes the shared secret and resists phishing in a way TOTP cannot.