Skip to main content

Command Palette

Search for a command to run...

Building Secure TOTP Two-Factor Authentication (RFC 6238) in Dart/Flutter

Updated
•9 min read•View as Markdown
Building Secure TOTP Two-Factor Authentication (RFC 6238) in Dart/Flutter
K

Hi I am Kasinadhsarma

Most "add 2FA" tickets turn into a scramble because the algorithm feels like it should be obscure, when really it's a short, well-specified RFC that Google Authenticator, Authy, 1Password, and every other TOTP app already implement identically. The value isn't in inventing your own scheme — it's in implementing the standard one correctly and then locking down everything around it: secret generation, storage, transport, and verification logic. Here's how a TotpService + TwoFactorService split like the one in daily_routine_sdk should work end to end, and where the real security risk actually lives.

The core primitive: HOTP (RFC 4226)

TOTP is just HOTP with time standing in for a counter. HOTP itself is simple: you HMAC a moving factor with a shared secret, then truncate the result down to a short decimal code.

HOTP(K, C) = Truncate(HMAC-SHA1(K, C)) mod 10^6

K is the shared secret, C is an 8-byte big-endian counter. The HMAC produces a 20-byte digest; "dynamic truncation" takes the low 4 bits of the last byte as an offset, reads 4 bytes starting there, masks off the top bit (to avoid sign issues when treated as a signed 32-bit int), and reduces mod 10^6 to get a 6-digit code:

int hotp(Uint8List key, int counter, {int digits = 6}) {
  final counterBytes = ByteData(8)..setInt64(0, counter, Endian.big);
  final hmac = Hmac(sha1, key);
  final digest = hmac.convert(counterBytes.buffer.asUint8List()).bytes;

  final offset = digest[digest.length - 1] & 0x0f;
  final binCode = ((digest[offset] & 0x7f) << 24) |
      ((digest[offset + 1] & 0xff) << 16) |
      ((digest[offset + 2] & 0xff) << 8) |
      (digest[offset + 3] & 0xff);

  final mod = pow(10, digits).toInt();
  return binCode % mod;
}

SHA-1 inside HMAC is not the same risk as SHA-1 for collision resistance — HMAC-SHA1 is still considered cryptographically sound for this use, which is why the RFC and every mainstream authenticator app still use it. Nothing here is "legacy crypto that needs replacing"; it's the interoperable default. SHA-256/SHA-512 variants exist and are configurable in some apps, but if you turn that on you lose compatibility with anything that hardcodes SHA-1 (Google Authenticator among them), so leave it alone unless you're building a closed ecosystem.

From HOTP to TOTP: time as the moving factor

RFC 6238 replaces the counter with a time step:

T = floor((unix_time - T0) / X)

T0 is the Unix epoch start (almost always 0), and X is the step size (almost always 30 seconds). That T is what gets fed into HOTP as the counter:

int currentTimeStep({int step = 30, int t0 = 0}) {
  final now = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000;
  return (now - t0) ~/ step;
}

int currentCode(Uint8List key, {int step = 30, int digits = 6}) {
  return hotp(key, currentTimeStep(step: step), digits: digits);
}

The 30-second window is a deliberate usability/security trade-off: short enough that a leaked code has a narrow blast radius, long enough that a human can read six digits off a phone and type them before the code rotates. Making the step configurable is fine for testing, but shipping anything other than 30s to end users breaks compatibility with every standard authenticator app, since they all assume it.

Generating the secret

The secret is the entire security boundary — if it leaks, TOTP protection is gone regardless of how correct the HMAC/truncation logic is. It has to come from a cryptographically secure random source, not Random(), and 160 bits (20 bytes) is the RFC-recommended length, matching HMAC-SHA1's natural key size:

Uint8List generateSecret({int lengthBytes = 20}) {
  final random = Random.secure();
  return Uint8List.fromList(
    List<int>.generate(lengthBytes, (_) => random.nextInt(256)),
  );
}

Random.secure() is Dart's CSPRNG — it's what makes this a security-relevant value instead of a predictable one. The raw bytes then get Base32-encoded, because Base32 is what QR-based enrollment and manual entry both expect (it's case-insensitive, has no padding ambiguity issues like Base64, and every authenticator app's "type this code" fallback assumes Base32).

The provisioning URI and QR enrollment

Enrollment hands the secret to the user's authenticator app via an otpauth:// URI, almost always rendered as a QR code so the raw secret never has to be typed:

String buildProvisioningUri({
  required String secretBase32,
  required String accountName,
  required String issuer,
  int digits = 6,
  int period = 30,
}) {
  final label = Uri.encodeComponent('$issuer:$accountName');
  final params = {
    'secret': secretBase32,
    'issuer': issuer,
    'digits': '$digits',
    'period': '$period',
    'algorithm': 'SHA1',
  };
  final query = params.entries
      .map((e) => '${e.key}=${Uri.encodeComponent(e.value)}')
      .join('&');
  return 'otpauth://totp/$label?$query';
}

Two things matter here beyond correctness of the format. First, this URI contains the raw secret in plaintext — it should only ever be rendered client-side into a QR code and never logged, sent to analytics, or persisted anywhere outside the encrypted enrollment flow. Second, the enrollment screen is the one place a secret exists outside of secure storage in cleartext, so that screen should have a short-lived state, no screenshots/screen-recording exposure where the platform allows blocking it, and a "confirm with a code" step before the secret is committed, so a botched enrollment doesn't lock an account into a secret nobody can actually generate matching codes for.

Verifying codes: the ±1 window, and why it stops there

Clock drift between a server and a user's phone, plus the few seconds it takes to read and type six digits, means a strict "does the code match the current time step" check fails constantly for legitimate users. The standard fix is to also check one step behind (and often one step ahead, to tolerate a phone's clock running fast):

bool verifyCode(Uint8List key, String inputCode, {int step = 30, int window = 1}) {
  final currentStep = currentTimeStep(step: step);
  for (var errorSteps = -window; errorSteps <= window; errorSteps++) {
    final candidate = hotp(key, currentStep + errorSteps);
    if (constantTimeEquals(candidate.toString().padLeft(6, '0'), inputCode)) {
      return true;
    }
  }
  return false;
}

bool constantTimeEquals(String a, String b) {
  if (a.length != b.length) return false;
  var result = 0;
  for (var i = 0; i < a.length; i++) {
    result |= a.codeUnitAt(i) ^ b.codeUnitAt(i);
  }
  return result == 0;
}

Two details separate a correct-looking implementation from a secure one. The comparison has to run in constant time — a naive == on strings can short-circuit on the first mismatched character, and while a single timing measurement won't leak a 6-digit code in practice, it's a cheap fix for a real class of side-channel bug, so there's no reason to skip it. And the window has to stay small: ±1 step at 30 seconds means a 90-second acceptance range, which is generous enough for real clock drift and slow typing but not so wide that it turns into a meaningfully longer brute-force window (a 6-digit code is 1,000,000 possibilities regardless of window size, but a wider window means more attempts succeed per unit of wall-clock time if someone is guessing). Anything past ±1 or ±2 should be treated as a sign that server or client clocks are badly out of sync, worth alerting on, not something to paper over by widening the window further.

The other essential control that lives outside the TOTP math entirely is rate limiting on the verify endpoint. TOTP's security assumption is that an attacker gets a bounded number of guesses before the code rotates — that assumption only holds if the server also enforces a low limit on verification attempts per account (something like 5 attempts, then a lockout or backoff), independent of anything the algorithm itself does. Without that, six digits is a weak barrier on its own.

Where the two services split responsibility

The separation between TotpService and TwoFactorService in this codebase maps to a security boundary worth keeping explicit. TotpService should be a pure algorithm implementation: no I/O, no storage, no knowledge of the app's auth flow — generate a secret, build a provisioning URI, compute or verify a code, nothing else. That makes it independently testable against the RFC 6238 test vectors (the RFC publishes known secret/time/code triples specifically so implementations can self-verify without guessing).

TwoFactorService is where the actual security work happens: it owns the decision of when a secret gets written to storage, which storage that is, and how verification attempts get rate-limited and logged. Storing the secret means secure, platform-backed storage — Keychain on iOS, Keystore-backed encrypted storage on Android (a package like flutter_secure_storage wraps both) — never SharedPreferences, a plain file, or anything that survives an adb backup or a jailbroken filesystem read. If enrollment ever needs to pass the secret through a backend (to sync 2FA state across devices, for instance), it has to travel over TLS and be encrypted at rest server-side too — the client-side secure storage doesn't help at all if the same value sits in a database in plaintext.

The checklist that actually matters

  1. Use Random.secure() (or an equivalent CSPRNG) for the secret, at least 160 bits — never a seeded or non-cryptographic RNG.

  2. Store the secret only in platform secure storage (Keychain/Keystore-backed), never in SharedPreferences, plain files, or app logs, and disable adb backup on Android for the app that holds it.

  3. Keep the verification window at ±1 step (90 seconds total at a 30-second period) — wide enough for real clock drift, not wide enough to meaningfully help a guessing attacker.

  4. Rate-limit verification attempts server-side (or locally if TOTP is used purely offline), independent of the TOTP algorithm itself — this is the control that makes a 6-digit code actually hard to brute-force.

  5. Compare codes with a constant-time equality check, not a standard string ==.

  6. Never log, transmit unencrypted, or persist the raw secret or the otpauth:// provisioning URI outside the enrollment flow.

  7. Provide backup/recovery codes generated at enrollment (single-use, hashed at rest like a password) so losing the authenticator device doesn't mean losing the account.

  8. Test against the RFC 6238 published test vectors before trusting a from-scratch implementation, rather than relying only on "it worked when I scanned it with my own phone."

The algorithm is the easy 20% of this feature. Getting HMAC-SHA1 and dynamic truncation right takes an afternoon and the RFC's test vectors will tell you immediately if you got it wrong. The 80% that determines whether the feature actually protects an account is everything around it: where the secret lives at rest, how attempts are rate-limited, and whether the comparison and enrollment flow leak anything a naive implementation wouldn't think to guard.