Originally published on PEAKIQ
Source: https://www.peakiq.in/blog/building-a-totp-authenticator-with-rust-and-react-native
Building a TOTP Authenticator with Rust and React Native
Two-factor authentication built around Time-based One-Time Passwords (TOTP) is one of those features that looks simple from the outside and turns out to have a surprising number of sharp edges once you actually build it: secret generation, QR provisioning, clock drift tolerance, secure storage on the client, and rate limiting on the server all need to work together correctly or the whole thing becomes either insecure or unusable.
This post walks through building a TOTP system end to end: a Rust backend that handles secret generation and verification, and a React Native client that scans an enrollment QR code, stores the secret securely, and displays a rolling six-digit code.
Why build this yourself
Most teams reach for a hosted identity provider for two-factor authentication, and for good reason — TOTP touches cryptography, secure storage, and rate limiting, all areas where a small mistake has real consequences. Building it yourself makes sense when you already own the authentication layer (your own user database, your own session model) and want TOTP as a native part of that system rather than a redirect to a third party.
This is also a genuinely good way to understand the protocol. TOTP is defined in RFC 6238, and it is small enough that implementing it from the spec, rather than treating it as a black box, makes every later debugging session easier — "why did this code fail to verify" stops being a mystery once you've written the HMAC step yourself.
How TOTP actually works
Before any code, it helps to be precise about what TOTP is doing:
-
Enrollment: the server generates a random secret (typically 160 bits) for the user and encodes it into an
otpauth://URI, which is rendered as a QR code. - Scanning: the user's authenticator app (or in our case, the React Native app itself) scans that QR code and extracts the secret.
-
Code generation: both the server and the client independently compute
HOTP(secret, time_counter), wheretime_counteris the current Unix time divided by a step size (usually 30 seconds), truncated to a 6-digit code. - Verification: when the user submits a code, the server recomputes the expected code for the current time step (and usually one step before/after, to tolerate clock drift) and compares.
The critical property is that the server never needs to ask the client for the secret again after enrollment — both sides derive the same code independently from the shared secret and the current time, with nothing transmitted over the wire except the 6-digit code itself.
Architecture overview
The system has two halves that need to agree on exactly one thing: the secret, and the algorithm parameters (digits, period, hash algorithm) used to turn that secret into a code.
┌─────────────────────┐ ┌──────────────────────┐
│ React Native App │ │ Rust API │
│ │ │ │
│ 1. Request enrollment│ ───────────────────> │ Generate secret │
│ │ │ Build otpauth:// URI │
│ 2. Render/scan QR │ <─────────────────── │ Return URI + secret │
│ │ │ │
│ 3. Store secret │ │ │
│ (Keychain/ │ │ │
│ Keystore) │ │ │
│ │ │ │
│ 4. Submit 6-digit │ ───────────────────> │ Verify against │
│ code │ │ stored secret + │
│ │ <─────────────────── │ time window │
└─────────────────────┘ └──────────────────────┘
A detail worth deciding early: does the React Native app scan a QR code generated by another authenticator's enrollment flow (the way Google Authenticator or Authy would), or does your own Rust backend generate the secret and your own app immediately consumes it without ever needing to scan anything? Both are valid, and this post covers the scanning path since it is the more general case — it also covers importing TOTP secrets from any third-party service that exposes a standard otpauth:// QR code, which is the more useful feature in practice.
The Rust backend
Dependencies
[dependencies]
totp-rs = { version = "5", features = ["qr"] }
rand = "0.8"
base32 = "0.5"
axum = "0.7"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.7", features = ["postgres", "runtime-tokio"] }
totp-rs handles the RFC 6238 math correctly, including the time-window tolerance, so there is no good reason to hand-roll the HMAC step yourself in production — the value of understanding the algorithm is in being able to reason about edge cases, not in reimplementing a primitive that a well-tested crate already gets right.
Generating a secret and enrollment URI
use totp_rs::{Algorithm, TOTP, Secret};
pub struct EnrollmentResponse {
pub secret_base32: String,
pub otpauth_uri: String,
}
pub fn create_enrollment(account_email: &str, issuer: &str) -> EnrollmentResponse {
let secret = Secret::generate_secret();
let totp = TOTP::new(
Algorithm::SHA1,
6,
1,
30,
secret.to_bytes().expect("valid secret bytes"),
Some(issuer.to_string()),
account_email.to_string(),
)
.expect("valid TOTP parameters");
EnrollmentResponse {
secret_base32: secret.to_encoded().to_string(),
otpauth_uri: totp.get_url(),
}
}
A few parameter choices here are worth calling out explicitly, because changing any of them breaks compatibility with standard authenticator apps:
-
Algorithm::SHA1— almost every authenticator app, including the React Native client built later in this post, assumes SHA1 unless told otherwise. SHA256/SHA512 are supported by the spec but poorly supported in practice. -
6digits — the near-universal default. Some banks use 8, but this breaks compatibility with most consumer authenticator apps. -
30second period — also near-universal. Shorter periods make codes expire before users can type them; longer periods widen the window an attacker has to use a leaked code. -
The
1parameter is the verification window (covered below) — it does not affect enrollment, only how lenientcheckis at verification time.
The secret itself (secret.to_bytes()) is what gets stored server-side, associated with the user's account, in your database — never the base32-encoded string alone without understanding that they are the same secret in two encodings. Store it encrypted at rest if your database doesn't already provide encryption at rest at the disk layer.
Storing the secret
#[derive(sqlx::FromRow)]
struct TotpEnrollment {
user_id: i64,
secret: Vec<u8>,
confirmed: bool,
}
pub async fn save_pending_enrollment(
pool: &sqlx::PgPool,
user_id: i64,
secret_bytes: &[u8],
) -> Result<(), sqlx::Error> {
sqlx::query(
"INSERT INTO totp_enrollments (user_id, secret, confirmed)
VALUES ($1, $2, false)
ON CONFLICT (user_id) DO UPDATE SET secret = $2, confirmed = false",
)
.bind(user_id)
.bind(secret_bytes)
.execute(pool)
.await?;
Ok(())
}
The confirmed flag matters more than it looks: a secret should not be treated as "the user's active TOTP secret" until they have proven they can generate a valid code from it. Without this step, a user could scan a QR code, never actually finish setting up their authenticator app correctly, and get locked out of their own account on the next login — or worse, an attacker who can see the enrollment response (but not necessarily the user's authenticator app) gains no benefit, but a confirmation step closes off any ambiguity about whether enrollment actually succeeded.
Verifying a submitted code
use totp_rs::{Algorithm, TOTP, Secret};
use std::time::{SystemTime, UNIX_EPOCH};
pub fn verify_code(secret_bytes: &[u8], submitted_code: &str) -> bool {
let totp = TOTP::new(
Algorithm::SHA1,
6,
1,
30,
secret_bytes.to_vec(),
None,
String::new(),
)
.expect("valid TOTP parameters");
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time before epoch")
.as_secs();
totp.check(submitted_code, now)
}
totp.check with a window of 1 (set in the TOTP::new call) accepts codes from the previous, current, and next 30-second step — a 90-second tolerance window total. This is what absorbs ordinary clock drift between the user's phone and your server without meaningfully weakening security; widening it further starts trading real security margin for marginal usability gains, since each extra step is another valid code an attacker could use if they intercepted one.
Rate limiting verification attempts
A TOTP code is only 6 digits — one million possibilities. Without rate limiting, brute-forcing a code within its 30-90 second validity window is computationally easy. This is the part of the system most likely to be skipped, and it is not optional.
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
pub struct AttemptTracker {
attempts: Mutex<HashMap<i64, Vec<Instant>>>,
}
impl AttemptTracker {
pub fn new() -> Self {
Self { attempts: Mutex::new(HashMap::new()) }
}
pub fn record_and_check(&self, user_id: i64, max_attempts: usize, window: Duration) -> bool {
let mut attempts = self.attempts.lock().expect("lock not poisoned");
let now = Instant::now();
let user_attempts = attempts.entry(user_id).or_insert_with(Vec::new);
user_attempts.retain(|t| now.duration_since(*t) < window);
if user_attempts.len() >= max_attempts {
return false;
}
user_attempts.push(now);
true
}
}
A reasonable starting point is something like 5 attempts per 5-minute window per user, returned as a 429 from your verification endpoint once exceeded. This in-memory tracker is fine for a single instance; if you're running multiple replicas behind a load balancer — which lines up with nginx-based scaling setups — this state needs to move to Redis so all replicas share the same attempt count instead of each one independently allowing 5 attempts.
The verification endpoint
use axum::{extract::State, Json};
use serde::Deserialize;
#[derive(Deserialize)]
pub struct VerifyRequest {
user_id: i64,
code: String,
}
pub async fn verify_totp_handler(
State(state): State<AppState>,
Json(payload): Json<VerifyRequest>,
) -> Result<Json<serde_json::Value>, axum::http::StatusCode> {
if !state
.attempt_tracker
.record_and_check(payload.user_id, 5, std::time::Duration::from_secs(300))
{
return Err(axum::http::StatusCode::TOO_MANY_REQUESTS);
}
let enrollment = sqlx::query_as::<_, TotpEnrollment>(
"SELECT user_id, secret, confirmed FROM totp_enrollments WHERE user_id = $1",
)
.bind(payload.user_id)
.fetch_optional(&state.pool)
.await
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
let is_valid = verify_code(&enrollment.secret, &payload.code);
Ok(Json(serde_json::json!({ "valid": is_valid })))
}
One subtlety: this endpoint returns { "valid": false } for a wrong code rather than a different status code for "wrong code" versus "no code submitted" versus "user not enrolled." Keeping these responses uniform avoids leaking information about which accounts have TOTP enabled to anyone probing the endpoint.
The React Native client
The client side has three jobs: scan a QR code and extract the secret, store that secret somewhere an attacker can't easily read it, and generate a rolling 6-digit code locally without needing to ask the server for one.
Dependencies
npm install react-native-vision-camera react-native-keychain otpauth
react-native-vision-camera handles the QR scan, react-native-keychain wraps iOS Keychain and Android Keystore for secure local storage, and otpauth is a small, well-tested JS implementation of RFC 6238 for generating codes on the client.
Parsing the scanned QR code
An otpauth:// URI looks like this:
otpauth://totp/MyApp:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=MyApp&algorithm=SHA1&digits=6&period=30
Once a barcode scanner outputs that raw string, parsing it is straightforward:
import * as OTPAuth from 'otpauth';
export function parseOtpAuthUri(rawValue: string) {
if (!rawValue.startsWith('otpauth://totp/')) {
throw new Error('Not a valid TOTP enrollment QR code');
}
const totp = OTPAuth.URI.parse(rawValue);
if (!(totp instanceof OTPAuth.TOTP)) {
throw new Error('QR code is not a TOTP entry');
}
return {
issuer: totp.issuer ?? '',
accountName: totp.label ?? '',
secret: totp.secret.base32,
algorithm: totp.algorithm,
digits: totp.digits,
period: totp.period,
};
}
This is the same shape of work your existing useQrScanner / scanRawValue flow already does for generic QR content — the only addition here is validating the otpauth:// scheme and handing the parsed fields off to a typed structure before anything gets stored.
Storing the secret securely
The secret must never land in AsyncStorage, Redux state persisted to disk, or anywhere else that isn't backed by the platform's hardware-backed secure storage. react-native-keychain is the standard choice:
import * as Keychain from 'react-native-keychain';
const TOTP_SERVICE = 'com.yourapp.totp';
export async function storeTotpSecret(accountKey: string, secret: string) {
await Keychain.setGenericPassword(accountKey, secret, {
service: `${TOTP_SERVICE}.${accountKey}`,
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});
}
export async function getTotpSecret(accountKey: string): Promise<string | null> {
const credentials = await Keychain.getGenericPassword({
service: `${TOTP_SERVICE}.${accountKey}`,
});
return credentials ? credentials.password : null;
}
WHEN_UNLOCKED_THIS_DEVICE_ONLY is the important option here: it prevents the secret from being included in iCloud Keychain backups or restored onto a different device, and it requires the device to be unlocked to read it back. For an authenticator secret, this is the correct tradeoff — losing access on device migration is the expected behavior, not a bug, and matches how dedicated authenticator apps behave.
Generating the live code
import * as OTPAuth from 'otpauth';
export function generateCurrentCode(secret: string): { code: string; secondsRemaining: number } {
const totp = new OTPAuth.TOTP({
secret: OTPAuth.Secret.fromBase32(secret),
algorithm: 'SHA1',
digits: 6,
period: 30,
});
const code = totp.generate();
const secondsRemaining = 30 - (Math.floor(Date.now() / 1000) % 30);
return { code, secondsRemaining };
}
A typical authenticator UI re-runs this every second on a timer to update both the displayed code and a countdown ring, regenerating only when the underlying code actually changes:
import { useEffect, useState } from 'react';
export function useLiveTotpCode(secret: string | null) {
const [state, setState] = useState<{ code: string; secondsRemaining: number } | null>(null);
useEffect(() => {
if (!secret) {
setState(null);
return;
}
const update = () => setState(generateCurrentCode(secret));
update();
const interval = setInterval(update, 1000);
return () => clearInterval(interval);
}, [secret]);
return state;
}
Wiring the scan into enrollment
This is where the scanning work fits into the existing CameraWithScanner pattern: once onScan fires with a raw value, route it through parseOtpAuthUri, and only call storeTotpSecret after the user confirms the parsed issuer and account name look correct — the same confirm-before-commit shape as the TotpReviewModal step in a typical scan flow, rather than silently saving the instant a QR code is detected.
const handleScan = useCallback((rawValue: string) => {
try {
const parsed = parseOtpAuthUri(rawValue);
setPendingEnrollment(parsed); // shown in a review modal before saving
} catch (error) {
Toast.show('This QR code is not a valid authenticator code.', Toast.SHORT);
}
}, []);
Security considerations
A short checklist worth treating as non-negotiable rather than nice-to-have:
- Never log the secret or the generated code, in either the Rust backend or the React Native app. Logging frameworks and crash reporters (Sentry included) need explicit scrubbing rules for any field that might contain a TOTP secret — this is the same category of concern as scrubbing PII, just with higher stakes if it leaks.
- Rate limit verification attempts server-side, as covered above. Rate limit per user, not just per IP, since a shared network (office Wi-Fi, mobile carrier NAT) can otherwise lock out unrelated users.
- Require a confirmed, valid code before marking enrollment complete. Don't treat "QR code was scanned" as equivalent to "TOTP is now protecting this account."
- Plan account recovery before launch, not after. Losing access to an authenticator app is common and predictable — back-up codes, generated once at enrollment and shown exactly once, are the standard mitigation.
- Keep the verification window tight. A window of 1 (90 seconds total) is a reasonable default; resist the temptation to widen it to "fix" user complaints about expired codes, since that's usually a UX problem (slow network round-trip, no countdown shown) rather than a clock-drift problem.
Testing the full flow
End to end, a manual test pass should cover:
- Enroll a new TOTP secret, scan the resulting QR code with the React Native client, confirm the parsed issuer/account name display correctly.
- Submit the live-generated code back to the verification endpoint and confirm it succeeds.
- Wait for a code to expire (just past the 30-second boundary) and confirm an old code is rejected once it falls outside the window.
- Submit 6 incorrect codes in a row and confirm the 6th request returns
429rather than a verification failure. - Kill and restart the app, confirm the stored secret is still readable from Keychain/Keystore and a fresh code generates correctly.
Where to go from here
This covers the core mechanics, but a production system typically layers on top of this: backup codes for recovery, a "remember this device for 30 days" cookie to avoid prompting on every login, and admin tooling to revoke a user's TOTP enrollment if their device is lost. The protocol itself, though, is exactly what's covered above — everything else is product decisions built on top of a correctly implemented RFC 6238 core.
Top comments (0)