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.
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.
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
- You expect a two-factor feature. PyOTP is arithmetic only: no secret storage, no encryption at rest, no replay rejection, no rate limiting, no backup codes, no enrollment flow. Its own README hands you a four-item checklist of things you must build yourself, and skipping the replay item means a stolen code stays valid for the rest of its window
- You are on Django. django-otp gives you device models, admin pages, and login integration; reimplementing that around PyOTP by hand ends up as a worse version of a package that already exists
- You are designing a new application from scratch. The PyOTP README itself tells you to consider WebAuthn or FIDO U2F instead, because TOTP relies on a shared secret sitting in your database and is phishable in real time
- You need secret encryption or drift tracking out of the box. PyOTP stores nothing and tracks nothing between calls; passlib.totp ships a key wallet for encrypting secrets at rest and returns match objects that tell you which counter matched
- You want a fast-moving dependency. Version 2.9.0 shipped in July 2023 and 2.10.0 in June 2026; the standards are frozen so that is defensible, but a bug you file will not be released quickly
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 hexrandom_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 stepvalid_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=Acmeissuer_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=60PyOTP 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 30Useful 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 stepAn 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 charactersSteam 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
| Package | Registry | Pick it when |
|---|---|---|
| passlib | PyPI | You 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-otp | PyPI | You are on Django and want device models, admin screens, and login integration instead of raw code generation. |
| webauthn | PyPI | You can require passkeys or security keys; WebAuthn removes the shared secret and resists phishing in a way TOTP cannot. |