I wanted two-factor auth on my self-hosted app, the kind where you scan a QR code
into Google Authenticator and type a 6-digit code. I reached for
django-two-factor-auth out of habit, looked at how much machinery it drags in for
a one-person tool, and backed away. The actual primitive — TOTP — is tiny:
pyotp does the math, qrcode draws the QR. The whole thing is about 40 lines.
But "about 40 lines" hides one line that decides whether your 2FA is real or pure
theater. I'll get there. First, enrollment.
Enrollment: the secret stays pending until they prove they scanned it
The naive flow is: generate a secret, save it on the user, show the QR. The bug in
that flow is subtle and mean — if the user fumbles the scan, or scans into the wrong
device, you've now flipped on 2FA with a secret they can't reproduce, and you've
locked them out of their own account.
So the secret lives in the session, not the user row, until they type a code that
proves they have it:
def totp_setup_view(request):
import pyotp, qrcode, io, base64
secret = pyotp.random_base32()
request.session['totp_pending_secret'] = secret # NOT saved to the user yet
name = request.user.email or request.user.username
uri = pyotp.TOTP(secret).provisioning_uri(name=name, issuer_name='Cloud Asset Manager')
buf = io.BytesIO(); qrcode.make(uri).save(buf, format='PNG')
qr_b64 = base64.b64encode(buf.getvalue()).decode()
return render(request, '_profile_totp_setup.html', {'secret': secret, 'qr_b64': qr_b64})
provisioning_uri() builds the otpauth://... URI the authenticator app expects;
qrcode turns it into a PNG; base64 inlines it straight into the page so there's no
image endpoint to wire up. Only when they come back with a working code do I persist
it:
def totp_confirm_view(request):
import pyotp
secret = request.session.get('totp_pending_secret')
code = request.POST.get('code', '').replace(' ', '')
if not secret:
return HttpResponse('...your session expired, start over...')
if not pyotp.TOTP(secret).verify(code):
return render(request, '_profile_totp_setup.html',
{'secret': secret, 'error': 'The verification code is incorrect'})
profile = _get_or_create_profile(request.user)
profile.totp_secret = secret
profile.two_factor_enabled = True
profile.save()
request.session.pop('totp_pending_secret', None)
# ...success partial...
Prove first, persist second. The DB never holds a secret the user hasn't
demonstrated they can generate codes for.
The one line: do NOT log them in before the code
Here's the part that decides whether any of this is worth doing. At login, the
password checks out. The tempting move is to call auth_login() and then show the
code prompt. If you do that, the user is already authenticated — the 6-digit
field is a speed bump they can navigate away from. That's not 2FA, it's a decoration.
The correct shape: on a 2FA user, stash a pending marker and redirect — but leave
them anonymous:
user = authenticate(request, username=username, password=password)
if user is not None:
if user.profile.two_factor_enabled and user.profile.totp_secret:
request.session['totp_pending_user_id'] = user.pk # a marker, NOT a login
next_url = request.GET.get('next', '/')
return redirect(f'/totp-verify/?next={next_url}')
auth_login(request, user) # only here, only without 2FA
return redirect(request.GET.get('next') or '/')
auth_login() for the second factor happens in exactly one place — after the code
verifies:
def totp_verify_view(request):
pending_id = request.session.get('totp_pending_user_id')
if not pending_id:
return redirect('/login/') # no half-finished state to land on
if request.method == 'POST':
import pyotp
code = request.POST.get('code', '').replace(' ', '')
user = _User.objects.get(pk=pending_id)
profile = UserProfile.objects.get(user=user)
if pyotp.TOTP(profile.totp_secret).verify(code):
del request.session['totp_pending_user_id']
auth_login(request, user) # NOW the session is authenticated
return redirect(request.GET.get('next') or '/')
return render(request, 'totp_verify.html', {'error': 'Wrong code'})
return render(request, 'totp_verify.html')
Between password and code the user holds a session key (totp_pending_user_id) and
nothing else. They can't reach any real page, because Django doesn't think they're
logged in. The if not pending_id: redirect('/login/') guard means there's no way to
land on the verify page out of order, either.
One related snag if you run gatekeeping middleware: my app forces every
authenticated request through an org-membership check, and I had to add
/totp-verify/ to its exempt prefixes — otherwise the redirect dance fights the
middleware. Worth checking your own middleware doesn't intercept the in-between step.
Turning it off should cost something
Disabling 2FA is a security downgrade, so it shouldn't be a one-click thing an
attacker on an unlocked session can do. Re-ask for the password:
def totp_disable_view(request):
if not request.user.check_password(request.POST.get('password', '')):
return HttpResponse('...password is incorrect...')
profile = _get_or_create_profile(request.user)
profile.two_factor_enabled = False
profile.totp_secret = ''
profile.save()
What I'd still harden
Being honest about the edges, since the whole point of self-hosting is you own them:
-
The secret is stored in plaintext in a
CharField. For a single-tenant, self-hosted box that's the same trust boundary as your password hashes — but encrypting it at rest (or a KMS-backed field) is the obvious next step if the DB is a bigger worry than the app server. - No recovery codes yet. Lose the phone, lose the account (short of an admin reset). One-time backup codes are the standard answer.
-
Clock skew:
pyotp'sverify()accepts only the current 30-second step by default. If users report "valid code rejected,"verify(code, valid_window=1)tolerates one step either side.
Takeaways
- You probably don't need a 2FA framework for TOTP.
pyotp+qrcodeis ~40 lines and you understand every one of them. - Keep the enrollment secret in the session until the user proves a working code, then persist. Never flip 2FA on with a secret they might not have.
- The line that matters: don't
auth_login()until the second factor verifies. Between password and code, the user is anonymous holding a pending marker — not logged in waiting to be asked nicely. - Gate the disable path behind a password re-entry, and watch for middleware that intercepts the in-between verify step.
This is the auth flow in a self-hosted AWS-vs-Terraform drift detector — open source
(MIT), one docker compose up: syncvey.com. Did you roll your
own TOTP or reach for django-two-factor-auth / django-otp? Curious where people
draw the build-vs-library line for auth specifically.










