Skip to main content

Command Palette

Search for a command to run...

Building a Secure, Cross-Platform TOTP Authenticator: A Deep Dive

Updated
6 min readView as Markdown

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.

Image of TOTP algorithm flowchart

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.

  1. Take the very last byte of the hash.

  2. Look at the last 4 bits (nibble) to get an "Offset" ($O$).

  3. Grab 4 bytes from the hash starting at that offset.

  4. Strip the "Sign Bit" (to avoid integer issues).

  5. 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.

ParameterTypeRequired?Notes
secretBase32 StringYESYou must handle padding inconsistencies (missing = signs).
algorithmStringNoDefaults to SHA1. Must support SHA256/512.
digitsIntegerNoDefaults to 6. Some legacy systems use 8.
periodIntegerNoDefaults to 30 seconds.
issuerStringNoUsed 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.

Image of Android Keystore architecture

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).

  1. Generate a Master Key: Create an AES-256 key in the Keystore.

  2. Encrypt the Secrets: Use that master key to encrypt the TOTP secrets before saving them to your database.

  3. StrongBox: On newer devices (Android 9+), use setIsStrongBoxBacked(true) to use the dedicated secure chip (like Titan M).

  4. 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.

  1. Access Control: When saving to Keychain, use kSecAccessControlBiometryAny. This ties the data to FaceID/TouchID.

  2. Accessibility: Use kSecAttrAccessibleWhenUnlocked. This ensures data is encrypted and inaccessible when the phone is locked.

  3. Prevent Cloning: Set kSecAttrSynchronizable to false (or use ThisDeviceOnly). 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?

More from this blog

TechOneGreen

9 posts