TOTP in C#, Spring, Laravel, and Django: a framework guide
The stack-specific companion to our TOTP implementation guide: real integration paths in C#/.NET, Spring, Laravel, and Django, plus the hardening every framework leaves to you.

RFC 6238 fits on a napkin: a shared secret, a counter derived from the current time, an HMAC, a truncation step, six digits. The napkin is not the problem. The problem is everything it touches — user table, login flow, session, rate limiter — and every framework has opinions about those. This is the parts catalogue: the same feature assembled in four stacks, the shortcuts each stack hands you, and the ways it lets you shoot yourself in the foot.
This is deliberately the second post in a pair. The implementation guide covers the framework-free version — how the codes are made, how to enroll users safely, how to harden verification, the test vectors. Read that one first if any of it is fuzzy; I'll lean on it and spend the words here on what changes in C#, Java, PHP, or Python. If TOTP itself is the fuzzy part, the primer is the gentlest on-ramp.
And if you read this and suspect you'd rather configure an identity provider than own any of it, that instinct is usually right — the operator's guide covers the same decision from the IdP side. The rest of this post assumes you checked, and the answer was "build."
C# and .NET: Otp.NET does the arithmetic
In .NET, the answer is Otp.NET — MIT-licensed, on NuGet, the package every serious C# TOTP implementation converges on. It implements RFC 4226 and 6238, handles Base32 and drift windows, and deliberately does not handle storage, enrollment UX, or rate limiting. The core:
// dotnet add package Otp.NET
var secret = KeyGeneration.GenerateRandomKey(20); // 160-bit secret
var base32 = Base32Encoding.ToString(secret); // goes in the otpauth:// URI
var totp = new Totp(secret); // SHA-1, 30s, 6 digits by default
bool ok = totp.VerifyTotp(code, out _,
new VerificationWindow(previous: 1, future: 1)); // one step of drift each way
GenerateRandomKey gives you raw key bytes; Base32Encoding.ToString gives you the string for the QR code and the otpauth URI. The Totp constructor has overloads for period, digits, and hash mode if your policy deviates — but most apps expect the defaults, so deviations are a support burden, not a security upgrade. VerifyTotp with one step of drift each way is the standard allowance, and its out parameter carries the matched time step — don't discard that in production; it's what you need for replay protection, more below.
For enrollment, render the otpauth URI as a QR code (QRCoder is the usual pairing) and show the Base32 secret alongside it for people who can't scan. For storage, never a plaintext column: ASP.NET Core's Data Protection API gives you encryption at rest with managed keys.
Spring: the framework owns the login, you supply the factor
Spring's answer is structural. Current Spring Security ships two-stage authentication: enable it in the configuration, and after the password stage the user carries a limited authority — the docs call it something like mfa_unverified — until the second factor completes and the context is upgraded. The DSL has moved between Spring Security 6.x releases, so treat that as the shape and the reference docs as the source of truth for your version.
What Spring Security doesn't do is the arithmetic; that comes from a small Java library — otp-java is the modern option, googleauth the battle-scarred one. The shape of the code, roughly:
// Illustrative — builder details vary by library and version.
var totp = TOTPGenerator.builder(secretBytes)
.algorithm(SHA1) // SHA-256/512 if your policy demands
.digits(6)
.period(Duration.ofSeconds(30))
.build();
String code = totp.now(); // what the user's app displays
boolean ok = totp.verify(code); // drift handling lives here
Two decisions you can't delegate. Where the secret lives: encrypt it at rest — a JPA attribute converter doing AES-GCM with a key from your secrets manager is the floor. And what the half-authenticated session may touch between stages: nothing interesting, which is the point of the limited authority. The classic bug is working around that restriction by marking the user fully authenticated after stage one and checking the token later. Don't.
Laravel: Fortify ships the whole flow
Laravel is the easy case for TOTP, and not by accident. Laravel Fortify — the headless authentication backend for whatever frontend you're building — includes two-factor authentication as a feature flag, with lineage running through google2fa, the PHP workhorse maintained for over a decade:
// config/fortify.php
'features' => [
Features::registration(),
Features::resetPasswords(),
Features::twoFactorAuthentication([
'confirm' => true, // require a live code before 2FA activates
'confirmPassword' => true, // re-auth to view secret, regenerate codes
]),
],
With that enabled, the flow exists: enabling two-factor generates a secret and an otpauth URI, Fortify serves the QR code, the user confirms with a live code, recovery codes are issued at confirmation. The post-password challenge screen is included too, rate-limited like the login. The Fortify docs list the routes and the packages you install alongside.
What's left is key management. The secret and recovery-code columns ship as Laravel encrypted casts — ciphertext under your application key. That moves the problem rather than solving it: a leaked APP_KEY decrypts every stored factor, and rotating one with users mid-flight deserves a runbook. The endpoint returning the plaintext secret for manual enrollment is gated by the confirm-password option. Keep that gate.
Django: django-otp for the protocol, django-two-factor-auth for the flow
Django splits the job cleanly. django-otp is the OTP framework: device models including a TOTP device, per-device throttling, and a drift setting (OTP_TOTP_SYNC_WINDOW) deciding how much clock skew is tolerated before codes stop matching. django-two-factor-auth, the jazzband-maintained layer on top, builds the experience: a login wizard that takes password then token, a setup wizard with the QR code, backup tokens via django-otp's static device, and — recently, still roughly experimental — WebAuthn via django-webauthn. Read the current docs before betting on that path.
# settings.py — the short version of the quickstart
INSTALLED_APPS = [
# ...
"django_otp",
"django_otp.plugins.otp_totp", # TOTP devices
"django_otp.plugins.otp_static", # backup tokens
"two_factor",
]
MIDDLEWARE = [
# ... after AuthenticationMiddleware
"django_otp.middleware.OTPMiddleware",
]
LOGIN_URL = "two_factor:login"
Wire two_factor.urls into your URLconf and the views are there — login, setup, backup tokens, the works. (django-otp's docs and the two-factor repo carry the details.) One gap to know: django-otp stores the device key in the database in the clear by default, and there's no encrypted-cast equivalent waiting for you. If you need encryption at rest for TOTP seeds, that's custom — yours to write.
The parts every framework leaves to you
The pattern across all four: the framework handles the arithmetic and the happy-path flow, and leaves the same chores unattended.
Encrypt the secret at rest. .NET has Data Protection, Laravel has the encrypted cast, Spring and Django hand you the gun loaded. The goal is identical: a database dump should not contain working TOTP seeds.
Reject replayed codes. A code is valid for its window, and if you don't track consumed codes, it's valid for a replay too. TOTP's counter is implicit — unlike HOTP's explicit one, nobody's counting for you — so store the last accepted time step per user (that out value in C#) and reject anything at or before it. No library does this for you, because it's state.
Rate-limit verification. Six digits is a million possibilities. Per-user backoff and lockout on the verify endpoint, same discipline as passwords. Fortify rate-limits its challenge; the others expect you to bring your own.
Own the recovery path. Backup codes, admin reset, a helpdesk script that doesn't quietly disable the factor. Most real-world MFA incidents are recovery-flow problems wearing a math costume.
Test with vectors. RFC 6238's appendix publishes known secret/step/code triples; the implementation guide has the details and gotchas. Run them against your stack before you trust a single real enrollment.
Then one check beyond the unit tests: enroll a test user in your flow and paste that test secret into our guest generator. Your server and an independent in-browser implementation agreeing on the same six digits at the same second is a thirty-second check that catches what unit tests miss — Base32-vs-hex slips, an unnoticed algorithm default, a period mismatch — bugs that only surface on a real phone, on a Friday.
Frequently asked questions
What is the best C# library for TOTP?
Otp.NET, and it isn't close: the package the .NET ecosystem has standardized on, implementing RFC 4226 and 6238 with Base32 handling and drift-aware verification built in. It stays deliberately thin — storage, enrollment UX, and rate limiting remain your job. Pair it with QRCoder for the enrollment QR code and ASP.NET Core Data Protection for secrets at rest.
Does Spring Security support TOTP?
It supports the hard structural part: two-stage authentication where a user who has finished only the password stage holds a limited authority until the second factor verifies. The TOTP arithmetic comes from a small library — otp-java or googleauth — with the secret encrypted at rest and the half-authenticated session kept short-lived. The configuration API has shifted across Spring Security 6.x releases, so build against the docs for your version.
How do I add two-factor authentication to Laravel?
Install Laravel Fortify, enable the twoFactorAuthentication feature in config/fortify.php with confirm and confirmPassword set, and run the migrations — the user table gains a secret column and a recovery-codes column, both encrypted casts. Fortify generates the QR code, requires a live code before activation, issues recovery codes, and rate-limits the login challenge. What's left for you is key management: the secrets are ciphertext under your application key.
How do I add 2FA to Django?
Combine django-otp and django-two-factor-auth: the first provides OTP device models and verification, the second provides the login wizard, setup views with QR codes, and backup tokens. It's a few installed apps, one middleware entry, and the two_factor URLs in your URLconf. The main gap to know about is that TOTP secrets are stored in the database in the clear by default, so encryption at rest is a custom job if you need it.
How do I test my TOTP implementation?
Two layers. First, the RFC 6238 appendix test vectors — fixed secret, fixed time steps, expected codes — which catch algorithm and encoding bugs deterministically, using an injected clock so the tests never sleep. Second, cross-implementation checks: enroll a test user, then compare your server's codes against an independent implementation. If both produce the same six digits in the same second, your encoding, algorithm, and period all line up.