Back to flin
flin

WhatsApp OTP Authentication for Africa

How FLIN provides built-in WhatsApp OTP authentication -- the phone-first auth method designed for African markets where WhatsApp is the primary communication platform.

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

In Silicon Valley, the default authentication method is email and password, optionally enhanced with Google Sign-In. This assumption fails spectacularly in Africa, where over 300 million people use WhatsApp daily but many do not have a personal email address. A student in Abidjan, a merchant in Lagos, a teacher in Nairobi -- they communicate through WhatsApp, pay through mobile money, and identify themselves by phone number.

Building FLIN without WhatsApp authentication would be like building an American web framework without Google Sign-In. It would technically work, but it would ignore how the target audience actually lives.

FLIN provides WhatsApp OTP as a built-in authentication method. Three functions -- whatsapp_send_otp(), otp_generate(), and the standard verification pattern -- handle the entire flow. No Twilio SDK. No third-party auth service. No per-message billing surprises.

The WhatsApp Authentication Flow

WhatsApp OTP uses a 3-step flow for new users and a 2-step flow for returning users:

New user:
1. Enter phone number -> Send OTP via WhatsApp
2. Enter OTP code -> Verify code
3. Complete profile (name, email, avatar)
4. Create account -> Dashboard

Returning user:
1. Enter phone number -> Send OTP via WhatsApp
2. Enter OTP code -> Verify code -> Dashboard (skip step 3)

The key insight is that the phone number is the identity. If the phone is already registered, the user is logged in after OTP verification. If not, they complete a profile form and an account is created.

Step 1: Send OTP

flin// app/auth/send-whatsapp-otp.flin

layout = "auth"

waPhone = session.waPhone || ""
otpSent = false

fn processSendWhatsappOtp() {
    if waPhone != "" {
        code = otp_generate(6)
        result = whatsapp_send_otp(waPhone, code)

        if result.success {
            session.waOtpCode = code
            session.waOtpPhone = waPhone
            otpSent = true
        }
    }
}
processSendWhatsappOtp()

{if otpSent}
    <h2>Enter the code sent to your WhatsApp</h2>
    <p>We sent a 6-digit code to {waPhone}</p>

    <input class="otp-input" type="text" bind={codeInput}
        maxlength="6" autocomplete="one-time-code" inputmode="numeric">

    <button click={
        session.waOtpInput = codeInput;
        location.href = "/auth/verify-whatsapp-otp"
    }>
        Verify Code
    </button>
{else}
    <p>An error occurred. Please try again.</p>
    <a href="/login">Back to login</a>
{/if}

The otp_generate(6) function creates a cryptographically random 6-digit code. The whatsapp_send_otp() function sends it via the WhatsApp Business API.

Step 2: Verify OTP

flin// app/auth/verify-whatsapp-otp.flin

layout = "auth"

otpInput = session.waOtpInput || ""
otpCode = session.waOtpCode || ""
otpPhone = session.waOtpPhone || ""

// Clear sensitive data immediately
session.waOtpInput = none
session.waOtpCode = none

verifyOk = false
isNewUser = false

fn processVerifyWhatsappOtp() {
    if otpInput != "" && otpCode != "" && otpPhone != "" {
        if otpInput == otpCode {
            existing = User.where(phone == otpPhone && role == "User").first

            if existing != none {
                // Returning user -- log in directly
                session.user = existing.email
                session.userName = existing.name || existing.firstName || existing.email
                session.userId = to_text(existing.id)
                session.waOtpPhone = none
                verifyOk = true
            } else {
                // New user -- need profile completion
                session.waVerifiedPhone = otpPhone
                session.waOtpPhone = none
                isNewUser = true
                verifyOk = true
            }
        }
    }
}
processVerifyWhatsappOtp()

{if verifyOk && !isNewUser}
    <h2>Welcome back!</h2>
    <script>setTimeout(function() { window.location.href = "/tasks"; }, 1000);</script>
{else if verifyOk && isNewUser}
    <h2>Phone verified! Let's set up your profile.</h2>
    <script>setTimeout(function() { window.location.href = "/auth/whatsapp-complete-profile"; }, 1200);</script>
{else}
    <h2>Invalid code</h2>
    <p>The code you entered is incorrect or has expired.</p>
    <a href="/login">Try again</a>
{/if}

The critical security pattern: OTP data is cleared from the session immediately after reading. The code exists in session storage for the minimum possible time.

Step 3: Profile Completion (New Users Only)

flin// app/auth/whatsapp-complete-profile.flin

layout = "auth"

waPhone = session.waVerifiedPhone || ""
errorKey = session.waCreateError || ""
session.waCreateError = none

{if waPhone == ""}
    <script>window.location.href = "/login";</script>
{else}
    <h2>Complete Your Profile</h2>

    <form method="POST" action="/auth/whatsapp-create-account" enctype="multipart/form-data">
        <input type="text" name="firstName" placeholder="First name" required>
        <input type="text" name="lastName" placeholder="Last name">
        <input type="email" name="email" placeholder="Email address" required>
        <input type="file" name="avatar" accept="image/*">
        <input type="text" name="occupation" placeholder="Occupation">
        <input type="text" name="country" placeholder="Country">

        {if errorKey != ""}
            <p class="error">{t(errorKey)}</p>
        {/if}

        <button type="submit">Create Account</button>
    </form>
{/if}
flin// app/auth/whatsapp-create-account.flin

route POST {
    validate {
        firstName: text @required @minLength(1)
        email: text @required @email
        lastName: text
        occupation: text
        country: text
        avatar: file @max_size("5MB")
    }

    waPhone = session.waVerifiedPhone || ""
    if waPhone == "" { redirect("/login") }

    // Check email uniqueness
    existingEmail = User.where(email == body.email && role == "User").first
    if existingEmail != none {
        session.waCreateError = "error.email_taken"
        redirect("/auth/whatsapp-complete-profile")
    }

    // Check phone uniqueness
    existingPhone = User.where(phone == waPhone && role == "User").first
    if existingPhone != none {
        session.user = existingPhone.email
        session.userName = existingPhone.name
        session.userId = to_text(existingPhone.id)
        session.waVerifiedPhone = none
        redirect("/tasks")
    }

    avatarPath = ""
    if body.avatar != none {
        avatarPath = save_file(body.avatar, ".flindb/avatars/")
    }

    fullName = to_text(body.firstName) + " " + to_text(body.lastName || "")
    newUser = User {
        email: body.email,
        name: fullName,
        firstName: body.firstName,
        lastName: body.lastName || "",
        phone: waPhone,
        provider: "WhatsApp",
        avatar: avatarPath,
        occupation: body.occupation || "",
        country: body.country || "",
        emailVerified: false
    }
    save newUser

    session.waVerifiedPhone = none
    session.user = newUser.email
    session.userName = fullName
    session.userId = to_text(newUser.id)

    redirect("/tasks")
}

The whatsapp_send_otp() Function

The built-in function handles the WhatsApp Business API integration:

flinresult = whatsapp_send_otp(phone_number, code)
// result.success -> true/false
// result.error -> error message if failed

Under the hood, FLIN sends the OTP through the WhatsApp Business API using a pre-approved message template. The template is configured once in the WhatsApp Business Manager and referenced by the runtime:

rustpub async fn send_whatsapp_otp(
    phone: &str,
    code: &str,
) -> Result<SendResult, WhatsAppError> {
    let api_url = env::var("WHATSAPP_API_URL")?;
    let token = env::var("WHATSAPP_TOKEN")?;
    let template_name = env::var("WHATSAPP_OTP_TEMPLATE")
        .unwrap_or_else(|_| "otp_verification".to_string());

    let payload = json!({
        "messaging_product": "whatsapp",
        "to": normalize_phone(phone),
        "type": "template",
        "template": {
            "name": template_name,
            "language": { "code": "en" },
            "components": [{
                "type": "body",
                "parameters": [{
                    "type": "text",
                    "text": code
                }]
            }]
        }
    });

    let response = reqwest::Client::new()
        .post(&api_url)
        .bearer_auth(&token)
        .json(&payload)
        .send()
        .await?;

    if response.status().is_success() {
        Ok(SendResult { success: true, error: None })
    } else {
        let error = response.text().await.unwrap_or_default();
        Ok(SendResult { success: false, error: Some(error) })
    }
}

Phone Number Normalization

African phone numbers come in many formats: +225 07 08 09 10, 00225 0708091010, 07 08 09 10, 225708091010. FLIN normalizes all of these to E.164 format (+2250708091010) before sending:

rustfn normalize_phone(phone: &str) -> String {
    // Remove spaces, dashes, parentheses
    let digits: String = phone.chars()
        .filter(|c| c.is_ascii_digit() || *c == '+')
        .collect();

    if digits.starts_with('+') {
        digits
    } else if digits.starts_with("00") {
        format!("+{}", &digits[2..])
    } else if digits.len() == 10 {
        // Assume local format -- need country code from config
        let country_code = env::var("DEFAULT_PHONE_COUNTRY").unwrap_or("225".into());
        format!("+{}{}", country_code, digits)
    } else {
        format!("+{}", digits)
    }
}

Why WhatsApp OTP for Africa

The decision to build WhatsApp OTP into FLIN was driven by market reality:

WhatsApp penetration. In Cote d'Ivoire, Nigeria, Kenya, Ghana, South Africa, and most of Sub-Saharan Africa, WhatsApp is the default messaging platform. Most people check WhatsApp before they check email.

Email scarcity. Many African internet users, especially outside major cities, do not have a personal email address. Requiring email registration excludes them.

Phone-first identity. Mobile money (MTN Mobile Money, Orange Money, Wave, M-Pesa) uses phone numbers as identifiers. Government services increasingly accept phone-based verification. A phone number is the most universal form of digital identity in Africa.

SMS costs. SMS-based OTP is expensive in Africa (0.03-0.10 USD per message) and unreliable across carriers. WhatsApp messages cost a fraction of that through the Business API and are delivered reliably over data connections.

Trust. Users trust messages from WhatsApp. A verification code received in WhatsApp feels legitimate. A code received via SMS might look like spam.

By making WhatsApp OTP a built-in feature, FLIN positions itself as a framework that understands its primary market. A developer in Abidjan building an app for West African users can add phone-based authentication in minutes, not days.

Security Considerations

WhatsApp OTP has specific security considerations:

Code expiration. FLIN's OTP codes are valid for 10 minutes. After that, the session data is invalidated and a new code must be requested.

Rate limiting. The send-OTP endpoint should be rate-limited to prevent abuse. The guard rate_limit(3, 300) guard (3 requests per 5 minutes) is recommended for OTP endpoints.

Code length. The 6-digit code provides 1 million possible combinations. Combined with rate limiting (5 attempts per session), brute force is impractical.

Session hygiene. OTP data is cleared from the session immediately after use. The code, the phone number, and the verification input are never stored longer than necessary.

FLIN's WhatsApp OTP is not a wrapper around a third-party auth service. It is a first-class authentication method built into the language, with the same session management, validation, and security guarantees as email/password authentication.

In the next article, we cover request body validators -- how FLIN's validate blocks enforce type safety, constraints, and business rules on incoming data before your handler code runs.


This is Part 112 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: - [111] OAuth2 and Social Authentication - [112] WhatsApp OTP Authentication for Africa (you are here) - [113] Request Body Validators - [114] 75 Security Tests: How We Verified Everything

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles