Building a Secure, Cross-Platform TOTP Authenticator: A Deep Dive
In today's security landscape, the static password is dead. Automated hacking tools and massive data breaches have made Multi-Factor Authentication (MFA) a requirement, not a luxury. Among the options—like biometrics or hardware keys—Time-based One-Time Passwords (TOTP) remain the industry standard. They are offline, decentralized, and governed by strict engineering rules (RFC 6238).
But building a production-grade authenticator is much harder than writing a "Hello World" script. You have to navigate cryptography, secure storage on Android/iOS, camera APIs, and proprietary migration formats.
This guide provides a comprehensive technical analysis of how to build a robust TOTP app, covering everything from the math to reverse-engineering Google Authenticator.
1. The Math: How TOTP Actually Works
TOTP isn't magic; it's math. It is an evolution of HOTP (Counter-based One-Time Password). While HOTP relies on a counter that increments every time you click a button, TOTP replaces that counter with the current time.
Shutterstock
The Formula
The core logic relies on HMAC-SHA1 (though SHA-256/512 are also supported). The TOTP value is calculated as:
$$TOTP(K, T) = \\text{Truncate}(\\text{HMAC-SHA-1}(K, T))$$
Where:
$K$: The shared secret key (unique to the user).
$T$: The time step.
Calculating the Time Step ($T$)
We don't use the raw time. We treat time as "steps" of 30 seconds.
$$T = \\lfloor \\frac{CurrentUnixTime - T0}{X} \\rfloor$$
CurrentUnixTime: Seconds since 1970 (Epoch).
X: The interval, usually 30 seconds.
This division creates a 30-second window where the code remains the same. Once the clock ticks past that window, $T$ increments by 1, resulting in a completely new hash.
Dynamic Truncation
The output of an HMAC-SHA1 is 20 bytes (160 bits). That's too long for a user to type. We use Dynamic Truncation to turn that hash into a 6-digit code.
Take the very last byte of the hash.
Look at the last 4 bits (nibble) to get an "Offset" ($O$).
Grab 4 bytes from the hash starting at that offset.
Strip the "Sign Bit" (to avoid integer issues).
Perform a Modulo operation to get the digits.
$$OTP = Binary \\mod 10^6$$
Developer Warning: Always ensure you treat the counter as an 8-byte big-endian integer. If you treat it as a standard 4-byte integer, your codes will fail verification.
2. The otpauth:// URI Scheme
When a user scans a QR code, they aren't scanning a random string; they are scanning a standardized URI. Although not an official RFC, the otpauth scheme is the de facto standard.
The Structure
Plaintext
otpauth://TYPE/LABEL?PARAMETERS
Example:
otpauth://totp/Google:jane.doe@gmail.com?secret=JBSWY3DPEHPK3PXP&issuer=Google
Parsing Parameters
Your app needs to parse these parameters robustly.
| Parameter | Type | Required? | Notes |
| secret | Base32 String | YES | You must handle padding inconsistencies (missing = signs). |
| algorithm | String | No | Defaults to SHA1. Must support SHA256/512. |
| digits | Integer | No | Defaults to 6. Some legacy systems use 8. |
| period | Integer | No | Defaults to 30 seconds. |
| issuer | String | No | Used for organizing tokens. |
Security Tip: Validate the secret immediately. Malicious QR codes can contain excessively long strings designed to crash your parser.
3. Secure Storage: The "Do Not Leak" Rule
The most critical part of your app is how you store the secrets. If you store them in plaintext (like a standard SQLite DB or SharedPreferences), malware can steal them. You need to use hardware-backed storage.
Shutterstock
Android: The Keystore System
On Android, use the Android Keystore System. This lets you generate keys that never leave the hardware (TEE or Secure Element).
Generate a Master Key: Create an AES-256 key in the Keystore.
Encrypt the Secrets: Use that master key to encrypt the TOTP secrets before saving them to your database.
StrongBox: On newer devices (Android 9+), use
setIsStrongBoxBacked(true)to use the dedicated secure chip (like Titan M).Biometrics: Use
setUserAuthenticationRequired(true)to force the user to unlock the phone (fingerprint/face) before the app can decrypt the keys.
iOS: Keychain and Secure Enclave
On iOS, use Keychain Services.
Access Control: When saving to Keychain, use
kSecAccessControlBiometryAny. This ties the data to FaceID/TouchID.Accessibility: Use
kSecAttrAccessibleWhenUnlocked. This ensures data is encrypted and inaccessible when the phone is locked.Prevent Cloning: Set
kSecAttrSynchronizabletofalse(or useThisDeviceOnly). This prevents the secrets from being copied to iCloud backups, where they could be extracted on a different device.
4. The "True Time" Problem
TOTP relies on the client and server having the exact same time. If a user's phone is fast or slow by just 90 seconds, their codes will be rejected.
Do not rely on System.currentTimeMillis(). Users often change their device time manually (e.g., to cheat in mobile games), which breaks TOTP.
The Solution: NTP (Network Time Protocol)
Your app should fetch the "real" time from an NTP server (like pool.ntp.org) and calculate the drift.
$$Offset = (ServerReceive - ClientTransmit) + (ServerTransmit - ClientReceive) / 2$$
Use libraries like TrueTime (Android/iOS). Instead of asking the OS for the time, you ask the library:
TrueTime.now() = DeviceUptime + CalculatedOffset.
5. Migration: Breaking Vendor Lock-in
A major barrier for users switching apps is data migration. Google Authenticator uses a proprietary format for its export QR codes. To build a great app, you should reverse-engineer this to allow users to import their data.
Decoding Google Authenticator
Google uses a Protocol Buffer (Protobuf) message inside a proprietary URI:
otpauth-migration://offline?data=...
To read this, you need to decode the Base64 data and then parse it using the following Protobuf schema:
Protocol Buffers
syntax = "proto2";
message MigrationPayload {
repeated OtpParameters otp_parameters = 1;
optional int32 version = 2;
optional int32 batch_size = 3;
optional int32 batch_index = 4;
optional int32 batch_id = 5;
}
message OtpParameters {
optional bytes secret = 1;
optional string name = 2;
optional string issuer = 3;
optional Algorithm algorithm = 4;
optional int32 digits = 5;
optional OtpType type = 6;
optional int64 counter = 7;
enum Algorithm {
ALGORITHM_UNSPECIFIED = 0;
ALGORITHM_SHA1 = 1;
ALGORITHM_SHA256 = 2;
ALGORITHM_SHA512 = 3;
ALGORITHM_MD5 = 4;
}
enum OtpType {
OTP_TYPE_UNSPECIFIED = 0;
HOTP = 1;
TOTP = 2;
}
}
By implementing this schema in your app, you can allow users to scan a Google export code and instantly transfer all their tokens to your secure app.
Conclusion
Building a TOTP authenticator is a rigorous exercise in engineering. It requires balancing strict cryptography with seamless user experience.
While the math (RFC 6238) is straightforward, the surrounding infrastructure—hardware-backed security, NTP time synchronization, and handling legacy import formats—is where the real work lies. As we move toward a passwordless future with FIDO2 and Passkeys, TOTP remains a critical bridge, and building a secure one is a valuable skill for any mobile engineer.
Next Steps for You
Would you like me to generate the Kotlin (Android) or Swift (iOS) code snippets for the HMAC-SHA1 implementation or the CameraX setup mentioned in this article?