Back to deblo
deblo

WhatsApp OTP and the African Authentication Problem

Email does not work. SMS is expensive. WhatsApp is universal. How we built authentication for Africa with WhatsApp OTP, Google OAuth, and student access codes.

Thales & Claude | March 25, 2026 15 min deblo
debloauthwhatsappotpjwtafricagoogle-oauth

By Thales & Claude -- CEO & AI CTO, ZeroSuite, Inc.

There is a student in Abidjan right now who wants to ask an AI tutor for help with her mathematics homework. She has a phone -- almost certainly an Android device running on a data bundle she bought this morning for 200 FCFA. She does not have a personal email address. She does not have a Google account configured on her device. She does not know what "two-factor authentication" means, and she should not have to.

She does, however, have WhatsApp. And that is the only assumption we need.

---

The Authentication Landscape in Sub-Saharan Africa

When you build an authentication system for a Western market, the playbook is well-established: email and password, maybe social login with Google or Apple, two-factor authentication via SMS or an authenticator app. This playbook assumes things that are not true for most of our users.

Here are the assumptions that break down in West and Central Africa:

Email is not universal. A significant portion of young Africans -- particularly students aged 8 to 18 -- do not have a personal email address. Some use a parent's email sporadically. Many have never created one. Building "sign up with email" as your primary flow means losing a large share of your addressable market before they even see your product.

SMS is expensive and unreliable. Sending an SMS to a phone number in Cote d'Ivoire costs roughly $0.03 to $0.08 per message depending on the carrier and aggregator. That may sound trivial, but at scale with retries and failed deliveries, it adds up fast. Worse, SMS delivery in West Africa is not guaranteed. Messages can be delayed by minutes or dropped entirely, especially during peak hours or when crossing carrier boundaries.

WhatsApp is universal. In most of the countries we serve -- Cote d'Ivoire, Senegal, Cameroun, Ghana, Kenya, Democratic Republic of Congo -- WhatsApp has over 90% penetration among smartphone users. It is the default messaging application. People check WhatsApp before they check anything else. A message sent via WhatsApp has a delivery rate that SMS can only dream of.

This is the foundation of our authentication strategy: WhatsApp first, SMS as fallback, Google OAuth as an alternative, and access codes for organizations where even phone numbers are not always available.

---

The OTP Flow: From Phone Number to JWT

The authentication flow in Deblo is deliberately simple. The user enters a phone number. We send a 6-digit OTP. They enter it. They are in. No password to remember, no email to verify, no complex signup form.

Here is the sequence:

1. User submits their phone number via POST /auth/send-otp 2. Backend generates a 6-digit OTP and stores it in the database with a 5-minute expiry 3. OTP is sent via WhatsApp (primary) and SMS (fallback) simultaneously 4. User enters the 6-digit code via POST /auth/verify-otp 5. Backend verifies the code, creates or retrieves the user, returns a JWT

The OTP generation and storage is straightforward. We generate a cryptographically random 6-digit code, store it in a OTPCode table with an expiry timestamp, and then fire off the delivery:

# backend/app/services/sms.py

async def create_otp(phone: str, db: AsyncSession) -> str: """Create and store an OTP for the given phone number.""" code = generate_otp() otp = OTPCode( phone=phone, code=code, expires_at=datetime.now(timezone.utc) + timedelta( minutes=settings.OTP_EXPIRY_MINUTES ), ) db.add(otp) await db.flush() await send_otp(phone, code) return code ```

The critical design decision here is in the send_otp function. We do not try WhatsApp first and then fall back to SMS if it fails. We fire both channels simultaneously using asyncio.gather, and the user receives whichever arrives first:

# backend/app/services/sms.py

async def send_otp(phone: str, code: str) -> bool: """Send OTP via both WhatsApp and SMS simultaneously.""" clean_phone = phone.lstrip("+")

# Fire both channels in parallel -- user gets whichever arrives first twilio_result, sms_result = await asyncio.gather( send_twilio_otp(clean_phone, code), send_smsing_otp(clean_phone, code), return_exceptions=True, )

twilio_ok = twilio_result is True sms_ok = sms_result is True

if twilio_ok or sms_ok: logger.info( f"OTP envoy\u00e9 \u00e0 {clean_phone[-4:]} " f"\u2014 Twilio: {twilio_ok}, SMS: {sms_ok}" ) return True

# Both channels failed -- log fallback for dev logger.warning( f"[OTP FALLBACK] Les deux canaux ont \u00e9chou\u00e9 pour " f"{clean_phone[-4:]}. Twilio: {twilio_result}, SMS: {sms_result}. " f"Code: {code}" ) return True ```

This parallel-send approach was not our first design. Initially, we tried WhatsApp first and only sent SMS after a 10-second timeout. The problem was that "WhatsApp failed" is not always immediately detectable -- sometimes the API returns 200 but the message never arrives. By sending both simultaneously, we maximize the chance the user gets the code within seconds, regardless of which channel is having a good day.

---

WhatsApp Business Cloud API Integration

The WhatsApp delivery path uses the WhatsApp Business Cloud API (version v24.0 at time of writing). This is Meta's official API for business messaging, and it works through template-based messages -- you cannot send arbitrary text to a user; you must use a pre-approved message template.

Our template is official_otp_code_template_english, which is one of Meta's pre-built OTP templates. The implementation looks like this:

# backend/app/services/whatsapp.py

async def send_whatsapp_otp(phone: str, code: str) -> bool: """Send OTP via WhatsApp template message.""" if not settings.WHATSAPP_PHONE_NUMBER_ID or not settings.WHATSAPP_ACCESS_TOKEN: logger.warning("WhatsApp API not configured, falling back to log") return False

url = ( f"https://graph.facebook.com/{settings.WHATSAPP_API_VERSION}" f"/{settings.WHATSAPP_PHONE_NUMBER_ID}/messages" )

payload = { "messaging_product": "whatsapp", "to": phone, "type": "template", "template": { "name": settings.WHATSAPP_OTP_TEMPLATE_NAME, "language": {"code": "en"}, "components": [ { "type": "body", "parameters": [{"type": "text", "text": code}], }, { "type": "button", "sub_type": "url", "index": "0", "parameters": [{"type": "text", "text": code}], }, ], }, }

async with httpx.AsyncClient(timeout=30.0) as client: response = await client.post(url, headers=headers, json=payload) data = response.json()

if response.status_code == 200: msg_id = data["messages"][0]["id"] logger.info( f"WhatsApp OTP sent to {phone[-4:]}, message_id={msg_id}" ) return True else: error = data.get("error", {}) logger.error( f"WhatsApp API error: {error.get('message')} " f"(code={error.get('code')})" ) return False ```

A few things worth noting about the WhatsApp Business Cloud API:

Template messages are free for the first 1,000 conversations per month per business account. After that, pricing depends on the conversation category and the user's country. Authentication templates (like OTPs) are classified as "utility" conversations and cost between $0.004 and $0.012 per conversation depending on the market. This is significantly cheaper than SMS.

The phone_number_id is not the phone number itself. It is an identifier assigned by Meta when you register a phone number with the WhatsApp Business Platform. You need a verified business account, a registered phone number, and a permanent access token. The setup process involves creating a Meta Business Suite app, verifying your business, and configuring webhooks -- it takes about a day the first time.

The button component is important. The OTP template includes a "copy code" button that auto-fills the OTP on the user's device. This is a significant UX improvement over SMS, where the user has to manually read and type the code.

---

The SMS Fallback: Twilio and Smsing

When WhatsApp is not available -- perhaps the user has WhatsApp but their data bundle has expired, or the WhatsApp API is having an outage -- the SMS channel picks up. We use two SMS providers for redundancy: Twilio (via WhatsApp Business API, which also supports SMS) and Smsing (a local West African SMS aggregator).

The Twilio path uses their content template API for WhatsApp delivery:

# backend/app/services/twilio_client.py

async def send_twilio_otp(phone: str, code: str) -> bool: """Send OTP via Twilio WhatsApp Business.""" url = ( f"https://api.twilio.com/2010-04-01" f"/Accounts/{settings.TWILIO_ACCOUNT_SID}/Messages.json" )

body = ( f"To=whatsapp:{phone}" f"&From=whatsapp:{settings.TWILIO_FROM}" f"&ContentSid={settings.TWILIO_CONTENT_SID}" f"&ContentVariables={quote_plus(json.dumps({'1': code}))}" )

async with httpx.AsyncClient(timeout=30.0) as client: response = await client.post( url, content=body.encode(), headers={ "Content-Type": "application/x-www-form-urlencoded" }, auth=( settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN, ), ) return response.status_code == 201 ```

One subtle detail: we build the Twilio request body manually instead of using httpx's data=dict parameter. The reason is that httpx percent-encodes colons (:) as %3A in the To and From fields, but Twilio expects literal colons in the whatsapp: prefix. This is the kind of bug that takes hours to diagnose and seconds to fix.

---

JWT: Thirty Days of Session Persistence

Once the OTP is verified, we issue a JSON Web Token with a 30-day expiry. This is deliberately long. Our reasoning: forcing a student to re-authenticate every few days adds friction to an already challenging environment. Many of our users are children who may not even remember which phone number they used. A 30-day session means they log in once and the app just works.

# backend/app/routes/auth.py

ALGORITHM = "HS256"

def _create_token(user_id: str) -> str: expire = datetime.now(timezone.utc) + timedelta( hours=settings.JWT_EXPIRY_HOURS ) payload = {"sub": user_id, "exp": expire} return jwt.encode(payload, settings.SECRET_KEY, algorithm=ALGORITHM) ```

The token is stored differently depending on the platform:

  • Web (SvelteKit): Stored in localStorage as part of the user object. Restored on app load via the deblo_user key.
  • Mobile (React Native / Expo): Stored in expo-secure-store (SecureStore), which uses the device's encrypted keychain (iOS Keychain / Android Keystore). This provides hardware-backed encryption at rest.

On mobile, the JWT restoration pattern looks conceptually like this:

// Mobile JWT restoration pattern (conceptual)
import * as SecureStore from 'expo-secure-store';

async function restoreSession(): Promise { const token = await SecureStore.getItemAsync('deblo_jwt'); if (!token) return null;

try { const response = await fetch(${API_BASE}/auth/me, { headers: { Authorization: Bearer ${token} }, }); if (response.ok) { const user = await response.json(); return { ...user, token }; } } catch { // Network error -- keep token for retry return null; }

// Token expired or invalid await SecureStore.deleteItemAsync('deblo_jwt'); return null; } ```

The mobile app also supports biometric unlock (Face ID on iOS, fingerprint on Android) as a convenience feature -- it does not replace the JWT; it gates access to the stored JWT so that a child cannot accidentally (or intentionally) use a parent's phone to access someone else's Deblo account.

---

Google OAuth: The Alternative Path

Not all users arrive via phone number. Some, particularly those using the web version or those with Google Workspace accounts through their school, prefer to sign in with Google. We support Google OAuth as a first-class alternative.

The flow is standard: the frontend initiates Google Sign-In, receives a Google ID token (a JWT itself), and sends it to POST /auth/google. The backend verifies the token against Google's tokeninfo endpoint:

# backend/app/routes/auth.py

async def _verify_google_token(id_token: str) -> dict: """Verify a Google ID token via Google's tokeninfo endpoint.""" async with httpx.AsyncClient() as client: resp = await client.get( "https://oauth2.googleapis.com/tokeninfo", params={"id_token": id_token}, timeout=10, ) if resp.status_code != 200: raise HTTPException(status_code=401, detail="google_token_invalid")

payload = resp.json()

if payload.get("aud") != settings.GOOGLE_CLIENT_ID: raise HTTPException( status_code=401, detail="google_token_invalid_audience" )

if payload.get("email_verified") != "true": raise HTTPException( status_code=401, detail="google_email_not_verified" )

return { "google_id": payload["sub"], "email": payload["email"], "name": payload.get("given_name", ""), "picture": payload.get("picture", ""), } ```

A critical design decision: we link Google accounts to existing phone accounts by email. If a user first signs up with their phone number and later adds an email to their profile, then tries to sign in with Google using that same email, we link the Google identity to their existing account rather than creating a duplicate. This prevents the "I have two accounts" problem that plagues many platforms.

---

Access Codes: Authentication Without a Phone

There is one more authentication path that exists specifically for the African context: access codes. Some of our users are members of organizations -- schools, tutoring centers, professional firms -- where the organization administrator creates accounts on behalf of members.

These members might be young children who do not have their own phone, or employees using a shared device. For these cases, we provide a 12-character access code plus a 4-digit PIN:

# backend/app/routes/auth.py

@router.post("/access-code-login") async def access_code_login( req: Request, request: AccessCodeLoginRequest, db: AsyncSession = Depends(get_db), ): """Login with global access code + PIN (for phone-less members).""" access_code = request.access_code.strip().upper().replace(" ", "") pin = request.pin.strip()

if not access_code or len(pin) != 4: raise HTTPException(status_code=401, detail="invalid_credentials")

result = await db.execute( select(User).where( User.access_code == access_code, User.login_pin == pin, User.is_active == True, ) ) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=401, detail="invalid_credentials")

token = _create_token(str(user.id)) return {"token": token, "user": {**_user_response(user), "is_new": False}} ```

The access code is generated by the organization admin and printed on a card or shared verbally. The PIN is set by the user on first login. No phone number, no email, no OTP -- just a code and a PIN. This is intentionally low-tech because the environments where it is needed are often low-tech.

---

Country Code Support: 34 Prefixes and Counting

One practical challenge of phone-based authentication in Africa is the sheer number of country codes and phone number formats. We support 34 phone country codes across our target markets, from +225 (Cote d'Ivoire) to +243 (Democratic Republic of Congo) to +233 (Ghana) to +254 (Kenya).

The frontend presents a country selector with flags, dials the correct prefix, and validates the local number format before sending the OTP request. On the backend, we normalize all phone numbers to international format without the + prefix (e.g., 2250709757296) for consistent storage and lookup.

---

The OTP Input Component: Six Boxes With Auto-Advance

On the web frontend, the OTP entry is a set of six individual input boxes that auto-advance as the user types. This is a small but important UX detail. When a user receives a 6-digit code on WhatsApp and switches to the browser, they are typing quickly and do not want to worry about focus management.

Each box accepts a single digit. On input, focus automatically moves to the next box. On backspace, focus moves to the previous box. On paste (from a WhatsApp "copy code" button), all six digits are distributed across the boxes simultaneously. When all six digits are filled, verification fires automatically -- no "Submit" button needed.

---

Security Considerations

A few security decisions worth documenting:

OTP expiry is 5 minutes. Short enough to prevent brute-force attacks (there are only one million possible 6-digit codes), long enough for a user on a slow connection to switch from WhatsApp to the browser.

OTPs are single-use. Once verified, the verified flag is set to True and the code cannot be reused. We check verified == False in the verification query.

No rate limiting on OTP requests... yet. This is a known gap. A malicious actor could theoretically trigger thousands of OTP sends to a phone number, costing us money in WhatsApp/SMS fees. We plan to add per-phone rate limiting (maximum 5 OTPs per hour per phone number) and CAPTCHA for suspicious patterns.

Soft delete, not hard delete. When a user deactivates their account, we set is_active = False rather than deleting the row. This preserves data for regulatory compliance and allows reactivation if the user contacts support.

---

What We Learned

Building authentication for the African market taught us several lessons that are not in any standard web development tutorial:

1. WhatsApp is infrastructure. In most of our markets, WhatsApp is not just a messaging app -- it is the primary digital communication channel. Treating it as a first-class authentication channel rather than a novelty was the right call.

2. Parallel delivery beats sequential fallback. Sending OTPs via multiple channels simultaneously is more reliable than a waterfall approach, even though it costs slightly more per authentication event.

3. Long sessions reduce churn. A 30-day JWT expiry sounds lax by Western security standards, but for an educational platform serving children in emerging markets, the cost of re-authentication friction far exceeds the security risk of a longer session.

4. Not everyone has a phone number. The access code system was an afterthought that became essential once we started onboarding schools and tutoring centers where students share devices.

5. Phone number portability is a real issue. In some markets, users change phone numbers frequently (switching between SIM cards for different carrier promotions). Supporting Google OAuth as an alternative identity anchor helps maintain account continuity.

The authentication system is one of those invisible features that nobody praises when it works and everyone notices when it breaks. For Deblo, making it invisible was the goal -- a student should go from "I want to learn" to "I am learning" in under 30 seconds.

---

This is article 5 of 12 in the "How We Built Deblo.ai" series.

1. The Architecture of an African AI Tutor 2. Prompt Engineering for 15 School Subjects 3. Photo Analysis: From Homework to AI 4. Building Deblo Pro: 101 AI Advisors for African Professionals 5. WhatsApp OTP and the African Authentication Problem (you are here) 6. Credits, FCFA, and 6 African Payment Gateways 7. SSE Streaming: Real-Time AI Responses in SvelteKit 8. Voice Calls With AI: Ultravox, LiveKit, and WebRTC 9. The Curriculum Engine: CEPE, BEPC, and BAC Prep 10. Gamification: XP, Streaks, and Bonus Credits 11. Going Mobile: React Native and Expo 12. From Abidjan to Production: Deploying Deblo

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles