Time-based one-time password (TOTP) for 2FA, part I

Previously: RSA SecurID token

Let's try to turn of 2FA TOTP on lichess.org website (the user is temp_user12)

What's in the QR code? (I made a screenshot and extracted the QR code using zbarimg.)

It's:

otpauth://totp/lichess.org:temp_user12?secret=XYYUFRCR3YP7DMHSZOLAQWBRTEODNAZL&issuer=lichess.org

The essence in the 'secret' part, base32-encoded binary blob.

Using Google Authenticator app, import that key (scan QR code), and it will show something like this. (The app doesn't allow taking screenshots, so this is a photo of my device.)

The code changes every 30 second. A pie-chart at right shows how much time left till next change.

Let's enter the code (it has changed while I work on this blog post).

Now lichess.org website will ask for a 6-digit code each time during login. A user have to run Google Authenticator app each time and read the code.

Now let's dig into internals. Internally, it's just HMAC-SHA-1, where that base32-encoded secret is key, and the message is Unix timestamp divided by 30:

#!/usr/bin/env python3
import hmac, hashlib, time, base64, sys, re

def hotp(key: bytes, ctr: int, length: int) -> str:
    m=hmac.new(key, digestmod=hashlib.sha1)
    m.update(ctr.to_bytes(8, 'big'))
    mac = m.digest()

    offset = mac[-1] & 0xf
    truncated = bytearray(mac[offset:offset+4])
    truncated[0] &= 0x7f
    value = int.from_bytes(truncated, 'big') % (10**length)

    return str(value).rjust(length, '0')

URL=sys.argv[1]
result=re.search('secret=(.*)&', URL)
assert result!=None
secret=result.group(1)
s=base64.b32decode(secret.upper())
print ("The code:", hotp(s,int(time.time()/30),6))
time_remaining=int(30-(time.time() % 30)) # remainder from division
print (f"The code is valid for {time_remaining} seconds")

(My note about HMAC.)

Pass an QR-decoded URL as an argument to the Python script and get a one-time code for login.

The server can calculate and verify entered code, because it have secret and knows current Unix timestamp.

Also, someone wrote a Python module for TOTP, pyotp, which makes things simpler/shorter:

#!/usr/bin/env python3
import pyotp, sys, time, datetime, re

URL=sys.argv[1]
result=re.search('secret=(.*)&', URL)
assert result!=None
secret=result.group(1)
totp=pyotp.TOTP(secret)
print ("The code:", totp.now())
time_remaining=int(totp.interval - datetime.datetime.now().timestamp() % totp.interval)
print (f"The code is valid for {time_remaining} seconds")

Corresponding RFCs: 4226, 6238.

Some interesting Wikipedia articles: 1, 2, 3, 4, 5.

I like to use TOTP, not because I'm paranoid, but because it's easier to use it with many popular online services (like Google, Github) instead of SMS or email confirmation.

But one problem is: that QR code or URL must be saved somewhere, because your smartphone with Google Authenticator installed may be lost or broken.

However, many online services, like Google, offer to download 'backup codes' that can help to restore access to your account without TOTP code.

But beware: the password and the QR code and/or backup codes should be hidden in separate places, because if an attacker will get password + QR code/backup codes at a single place, he/she will be able to login to your account. Or, it all should be properly encrypted and stored, maybe using a password manager.

Also, do not forget that Google Authenticator itself doesn't have any protection at all -- it has a convenient export function, to export the stored URL (with secret) in form of QR code.

(the post first published at 20260910.)


List of my other blog posts. Subscribe to my news feed,
If you noticed a typo/bug/error or have any suggestions, do not hesitate to drop me a note: my emails. Or use my zulip for feedback. Or Discord. Thanks in advance!
Also, among my services is writing examples-rich manuals, references and help files. If you like my work and want something similar for your (commercial) product: contact me.
If you enjoy my work, you can support it on patreon.
Some time ago (before 24-Mar-2025) there was Disqus JS script for comments. I dropped it --- it was so motley, distracting, animated, with too much ads. I never liked it. Also, comments din't appeared correctly (Disqus was buggy). Also, my blog is too chamberlike --- not many people write comments here. So I decided to switch to the model I once had at least in 2020 --- send me your comments by email (don't forget to include URL to this blog post) and I will copy&paste it here manually.
Let's party like it's ~1993-1996, in this ultimate, radical and uncompromisingly primitive pre-web1.0-style blog and website. This website is best viewed under lynx/links/elinks/w3m.