Back to flin
flin

Two-Factor Authentication (TOTP)

How FLIN implements TOTP two-factor authentication as a built-in feature -- secret generation, QR codes, verification, and backup codes in four function calls.

Thales & Claude | March 25, 2026 8 min flin
flin2fatotpauthentication

Passwords alone are not enough. Phishing attacks, credential stuffing, and database breaches mean that even strong, unique passwords can be compromised. Two-factor authentication adds a second layer: something you have (your phone) in addition to something you know (your password).

TOTP (Time-Based One-Time Password) is the most widely deployed 2FA standard. It generates a 6-digit code that changes every 30 seconds, based on a shared secret and the current time. Google Authenticator, Authy, 1Password, and Bitwarden all support it.

FLIN implements TOTP as four built-in functions: generate_totp_secret(), totp_qr_url(), verify_totp(), and generate_backup_codes(). No library. No external service. No configuration.

Enabling 2FA for a User

The setup flow is straightforward: generate a secret, show the QR code, verify the first code, and store the secret.

flin// app/settings/two-factor.flin

guard auth

secret = ""
qr_url = ""
setup_complete = false

fn begin_setup() {
    secret = generate_totp_secret()
    session.pending_totp_secret = secret
    qr_url = totp_qr_url(secret, session.user, "MyApp")
}

fn verify_setup(code) {
    pending = session.pending_totp_secret
    if pending != "" && verify_totp(pending, code) {
        user = User.find(to_int(session.userId))
        user.totp_secret = pending
        user.totp_enabled = true
        save user

        session.pending_totp_secret = none
        setup_complete = true
    }
}

<main>
    {if setup_complete}
        <h2>Two-factor authentication enabled</h2>
        <p>Your account is now protected with 2FA.</p>
    {else if qr_url != ""}
        <h2>Scan this QR code with your authenticator app</h2>
        <img src={qr_url} alt="TOTP QR Code">
        <p>Or enter this secret manually: <code>{secret}</code></p>
        <input type="text" placeholder="Enter the 6-digit code" bind={code}>
        <button click={verify_setup(code)}>Verify and Enable</button>
    {else}
        <button click={begin_setup()}>Set Up Two-Factor Authentication</button>
    {/if}
</main>

The QR code contains a otpauth:// URL that authenticator apps recognize. Scanning it adds the account automatically. The manual secret entry is a fallback for users who cannot scan QR codes.

The Four TOTP Functions

generate_totp_secret()

Generates a 20-byte (160-bit) random secret encoded in Base32:

flinsecret = generate_totp_secret()
// Returns: "JBSWY3DPEHPK3PXP4WBQGZLSMY"

The secret is generated using the operating system's cryptographic random number generator. It is never predictable, never reused, and never derived from user data.

totp_qr_url(secret, account, issuer)

Generates a URL for a QR code image containing the TOTP setup information:

flinqr_url = totp_qr_url(secret, "[email protected]", "MyApp")
// Returns a data URL or a URL to the QR code image

The QR code encodes a URI like: otpauth://totp/MyApp:[email protected]?secret=JBSWY3DPEHPK3PXP4WBQGZLSMY&issuer=MyApp&algorithm=SHA1&digits=6&period=30

verify_totp(secret, code)

Verifies a 6-digit TOTP code against the secret:

flinis_valid = verify_totp(secret, "483927")
// Returns: true or false

The verification accepts codes from the current time step, the previous time step, and the next time step (a window of 90 seconds total). This compensates for clock drift between the server and the user's phone.

generate_backup_codes(count)

Generates one-time recovery codes for users who lose access to their authenticator:

flincodes = generate_backup_codes(10)
// Returns: ["A1B2-C3D4", "E5F6-G7H8", "I9J0-K1L2", ...]

Each code is a unique, random, 8-character string that can be used once instead of a TOTP code. After use, the code is invalidated.

The Login Flow with 2FA

When 2FA is enabled, the login process adds a verification step:

flin// app/api/auth/login.flin

guard rate_limit(5, 60)

route POST {
    validate {
        email: text @required @email
        password: text @required
    }

    user = User.where(email == body.email).first
    if user == none || !verify_password(body.password, user.password) {
        return error(401, "Invalid credentials")
    }

    // Check if 2FA is enabled
    if user.totp_enabled {
        // Issue a temporary token that requires 2FA completion
        temp_token = create_token(user, {
            expires: "5m",
            claims: { type: "2fa_pending", requires_2fa: true }
        })

        return {
            requires_2fa: true,
            temp_token: temp_token
        }
    }

    // No 2FA -- issue full access token
    token = create_token(user, { expires: "7d" })
    { access_token: token, user: user }
}
flin// app/api/auth/verify-2fa.flin

guard rate_limit(5, 60)

route POST {
    validate {
        temp_token: text @required
        code: text @required
    }

    claims = verify_token(body.temp_token)
    if claims == none || claims.type != "2fa_pending" {
        return error(401, "Invalid or expired token")
    }

    user = User.find(to_int(claims.sub))
    if user == none {
        return error(401, "User not found")
    }

    // Try TOTP code
    if verify_totp(user.totp_secret, body.code) {
        token = create_token(user, { expires: "7d" })
        return { access_token: token, user: user }
    }

    // Try backup code
    backup = BackupCode.where(user_id == user.id && code == body.code && used == false).first
    if backup != none {
        backup.used = true
        save backup
        token = create_token(user, { expires: "7d" })
        return { access_token: token, user: user }
    }

    error(401, "Invalid 2FA code")
}

The flow is: 1. User sends email and password. 2. If password is correct and 2FA is enabled, the server returns a short-lived temporary token. 3. The client shows the 2FA input screen. 4. The user enters their TOTP code (or backup code). 5. The server verifies the code and issues the full access token.

The temporary token expires in 5 minutes, preventing replay attacks.

TOTP Implementation Details

The TOTP algorithm (RFC 6238) is built on HOTP (HMAC-Based One-Time Password, RFC 4226):

rustuse hmac::{Hmac, Mac};
use sha1::Sha1;

type HmacSha1 = Hmac<Sha1>;

const TOTP_PERIOD: u64 = 30;    // 30-second time step
const TOTP_DIGITS: u32 = 6;     // 6-digit code
const TOTP_WINDOW: i64 = 1;     // Accept +/- 1 time step

pub fn generate_totp(secret: &[u8], time: u64) -> String {
    let counter = time / TOTP_PERIOD;
    let counter_bytes = counter.to_be_bytes();

    let mut mac = HmacSha1::new_from_slice(secret).unwrap();
    mac.update(&counter_bytes);
    let hmac_result = mac.finalize().into_bytes();

    // Dynamic truncation
    let offset = (hmac_result[19] & 0x0f) as usize;
    let code = ((hmac_result[offset] as u32 & 0x7f) << 24)
        | ((hmac_result[offset + 1] as u32) << 16)
        | ((hmac_result[offset + 2] as u32) << 8)
        | (hmac_result[offset + 3] as u32);

    let otp = code % 10u32.pow(TOTP_DIGITS);
    format!("{:0>width$}", otp, width = TOTP_DIGITS as usize)
}

pub fn verify_totp_code(secret: &[u8], code: &str) -> bool {
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs();

    for offset in -TOTP_WINDOW..=TOTP_WINDOW {
        let time = (now as i64 + offset * TOTP_PERIOD as i64) as u64;
        if constant_time_eq(code.as_bytes(), generate_totp(secret, time).as_bytes()) {
            return true;
        }
    }
    false
}

Key security details:

Time window. The verification checks the current time step plus one before and one after (90 seconds total). This handles clock drift without making the window so wide that codes become reusable.

Constant-time comparison. Even for TOTP codes, comparison is constant-time. An attacker cannot determine which digits are correct by measuring response time.

Replay prevention. A used TOTP code should ideally be rejected for the remainder of its time step. FLIN tracks the last used counter per user to prevent immediate replay.

Backup Codes

Backup codes are generated as random 8-character strings and stored as hashed values in the database:

flin// Generate and show to user (one time only)
codes = generate_backup_codes(10)
for code in codes {
    save BackupCode {
        user_id: user.id,
        code_hash: hash_password(code),
        used: false
    }
}

// Show codes to user -- they must save them

Backup codes are hashed before storage because they are equivalent to a password. If the database is compromised, the attacker cannot use the backup codes directly.

When a user enters a backup code, the verification checks all unused backup codes for that user:

flinfn verify_backup(user_id, code) {
    backups = BackupCode.where(user_id == user_id && used == false)
    for backup in backups {
        if verify_password(code, backup.code_hash) {
            backup.used = true
            save backup
            return true
        }
    }
    return false
}

Disabling 2FA

Users can disable 2FA after providing their current TOTP code (to prove they still have access to their authenticator):

flinroute POST "/disable-2fa" {
    guard auth

    validate {
        code: text @required
    }

    user = User.find(to_int(session.userId))

    if !verify_totp(user.totp_secret, body.code) {
        return error(401, "Invalid 2FA code")
    }

    user.totp_enabled = false
    user.totp_secret = ""
    save user

    // Delete remaining backup codes
    BackupCode.where(user_id == user.id).delete_all

    { success: true, message: "Two-factor authentication disabled" }
}

The requirement to provide a valid TOTP code before disabling 2FA prevents an attacker who has stolen a session from disabling the second factor.

FLIN's 2FA implementation is four functions, zero dependencies, and a standard protocol that works with every authenticator app in existence. The developer does not need to understand HMAC, SHA-1, dynamic truncation, or Base32 encoding. They call the functions, and the security is there.

In the next article, we cover OAuth2 and social authentication -- how FLIN connects to Google, GitHub, Discord, Apple, LinkedIn, and Telegram with built-in auth functions.


This is Part 110 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO designed and built a programming language from scratch.

Series Navigation: - [109] Rate Limiting and Security Headers - [110] Two-Factor Authentication (TOTP) (you are here) - [111] OAuth2 and Social Authentication - [112] WhatsApp OTP Authentication for Africa

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles