Security

Bypassing SMS OTP: Implementing WebAuthn Device Binding and Telco SIM-Swap Checks in Node.js

A
Adebayo FalojuPrincipal Systems Architect
September 30, 202618 min read
Bypassing SMS OTP: Implementing WebAuthn Device Binding and Telco SIM-Swap Checks in Node.js

SMS OTP is no longer a safe primary authentication factor for Nigerian financial applications. Learn how to implement hardware-bound WebAuthn credentials and real-time telco SIM-swap checks to eliminate account takeover fraud.

At 2:14 AM on a Tuesday, an attacker walked away with ₦4.8 million from a user's wallet on a popular Nigerian wealth management app. The breach did not happen because of a database leak or a compromised database password. It happened at a roadside SIM registration kiosk in Computer Village, Ikeja.

An attacker cloned the victim's MTN SIM using a forged National Identity Number (NIN) slip and social engineering. With the hijacked phone number active in a ₦15,000 burner phone, the attacker triggered a reset flow on the fintech app, intercepted the SMS OTP, changed the transaction PIN, and emptied the wallet via Instant Payment to a Microfinance Bank account. The entire attack took less than eleven minutes. The victim was fast asleep.

Relying on SMS OTP for step-up authentication or account recovery in West Africa is an architectural liability. High SIM-swap fraud rates, Telco insider threats, and SMS interception make phone numbers terrible security tokens. To stop account takeover (ATO), you must decouple identity verification from phone network signaling.

This guide shows you how to implement hardware-bound cryptographic keys using WebAuthn (FIDO2) alongside real-time telco SIM-swap detection APIs to secure high-risk transactions in Node.js.

Architecture diagram showing WebAuthn public key authentication and telco SIM swap API check before authorizing a high-value NIP transfer

The Architecture of Hardware-Bound Authentication

SMS messages travel in plaintext through mobile network operators, aggregators, and local routing nodes. Anyone with access to the telco signaling network (SS7/Diameter) or a compromised SIM agent portal can read your user's OTP.

WebAuthn shifts authentication from shared secrets (like OTPs or passwords) to asymmetric public-key cryptography tied directly to the phone's Secure Enclave or Android Keystore (Trusted Execution Environment).

When a user registers their account on your app, the mobile OS generates a unique RSA or ECC key pair inside hardware. The private key never leaves the hardware security module. The public key is sent to your backend server. Future transactions require the user to sign a backend-generated challenge using biometrics (Fingerprint/FaceID) or device PIN.

To complement device binding, you must check whether the user's mobile number was swapped recently before initiating high-risk actions. If a SIM swap occurred within the last 48 hours, you restrict money movement and force secondary verification using biometric liveness checks, similar to the workflows detailed in our breakdown of Smile ID vs. Prembly vs. Youverify for Nigerian KYC.

When combined with robust field protection like BVN Envelope Encryption under NDPR 2023, hardware device binding eliminates the vast majority of credential stuffing and remote SIM hijacking vectors.

[Client Mobile App]          [Backend Node.js API]          [Telco Gateway / DB]
        |                              |                             |
        |---- 1. Initiate Transfer --->|                             |
        |                              |---- 2. Query SIM Swap ----->|
        |                              |<--- 3. Last Swap: 120h ago -|
        |<--- 4. Challenge (Nonce) ----|
        |
 5. Hardware Sign (TEE)
        |
        |---- 6. Signature Response -->|
        |                              |---- 7. Verify & Process ---->

Step 1: Registering WebAuthn Passkeys on Mobile Devices

To build hardware device binding, your backend needs to issue registration challenges and store public key credentials. We will use the standard @simplewebauthn/server library, which implements the W3C WebAuthn Level 2 standard.

First, install the required dependencies:

npm install @simplewebauthn/server dotenv express ioredis pg

Set up the server route to generate registration options. This endpoint sends a cryptographically random challenge to the mobile app or web frontend.

// src/auth/webauthn.ts
import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server';
import { Request, Response } from 'express';
import Redis from 'ioredis';
import { db } from '../db';

const redis = new Redis(process.env.REDIS_URL!);
const RP_NAME = 'Neobot Pay';
const RP_ID = 'api.neobotpay.ng';

export async function getRegistrationChallenge(req: Request, res: Response) {
  const userId = req.user.id;
  const userEmail = req.user.email;

  // Fetch existing authenticators to prevent duplicate registration
  const existingKeys = await db.query(
    'SELECT credential_id FROM user_credentials WHERE user_id = $1',
    [userId]
  );

  const options = await generateRegistrationOptions({
    rpName: RP_NAME,
    rpID: RP_ID,
    userID: Buffer.from(userId),
    userName: userEmail,
    attestationType: 'direct',
    excludeCredentials: existingKeys.rows.map((row) => ({
      id: row.credential_id,
      type: 'public-key',
    })),
    authenticatorSelection: {
      authenticatorAttachment: 'platform', // Enforce Secure Enclave / Android Keystore
      userVerification: 'required',
      residentKey: 'required',
    },
  });

  // Save challenge in Redis with a short 5-minute expiry
  await redis.setex(`webauthn_challenge:${userId}`, 300, options.challenge);

  return res.json(options);
}

Once the client app receives this payload, it invokes native biometric APIs (e.g., navigator.credentials.create() or native Android Credentials Manager) to sign the challenge using the device hardware.

Now, implement the verification endpoint that processes the device attestation and saves the public key:

export async function verifyRegistration(req: Request, res: Response) {
  const userId = req.user.id;
  const body = req.body;

  const expectedChallenge = await redis.get(`webauthn_challenge:${userId}`);
  if (!expectedChallenge) {
    return res.status(400).json({ error: 'Challenge expired or invalid' });
  }

  let verification;
  try {
    verification = await verifyRegistrationResponse({
      response: body,
      expectedChallenge,
      expectedOrigin: 'https://api.neobotpay.ng',
      expectedRPID: RP_ID,
    });
  } catch (error: any) {
    return res.status(400).json({ error: error.message });
  }

  const { verified, registrationInfo } = verification;
  if (!verified || !registrationInfo) {
    return res.status(400).json({ error: 'Device attestation failed' });
  }

  const { credential, credentialPublicKey, counter } = registrationInfo;

  // Store the public key in Postgres
  await db.query(
    `INSERT INTO user_credentials 
      (user_id, credential_id, public_key, counter, device_model, created_at) 
     VALUES ($1, $2, $3, $4, $5, NOW())`,
    [
      userId,
      credential.id,
      Buffer.from(credentialPublicKey),
      counter,
      req.headers['user-agent'] || 'Unknown Device',
    ]
  );

  await redis.del(`webauthn_challenge:${userId}`);
  return res.json({ status: 'success', message: 'Device bound successfully' });
}

Step 2: Integrating Telco SIM-Swap Verification via CAMARA Standards

Hardware device binding prevents remote logins on new devices. But what if an attacker gains access to a user's logged-in session or attempts a high-risk wallet drain? You need a real-time check against the mobile network operators (MTN, Airtel, Glo, 9mobile) to verify if the phone number was swapped recently.

Major West African telecom operators are adopting the GSMA CAMARA Open Gateway standard for SIM Swap APIs. Below is a production module that queries the telco API gateway for SIM swap history before processing money transfers.

// src/services/simSwapCheck.ts
import axios from 'axios';
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL!);
const TELCO_GATEWAY_URL = process.env.TELCO_GATEWAY_URL!;
const TELCO_API_KEY = process.env.TELCO_API_KEY!;

interface SimSwapResponse {
  swapped: boolean;
  lastSwappedAt?: string; // ISO Timestamp
}

export async function checkSimSwapStatus(phoneNumber: string): Promise<SimSwapResponse> {
  // Standardize phone number format (+234...)
  const formattedPhone = phoneNumber.startsWith('0')
    ? `+234${phoneNumber.slice(1)}`
    : phoneNumber;

  // Cache result for 15 minutes to reduce API latency on repeated actions
  const cacheKey = `sim_swap_status:${formattedPhone}`;
  const cached = await redis.get(cacheKey);
  if (cached) {
    return JSON.parse(cached);
  }

  try {
    const response = await axios.post(
      `${TELCO_GATEWAY_URL}/sim-swap/v0/check`,
      {
        phoneNumber: formattedPhone,
        maxAgeHours: 48, // Flag swaps occurring in the last 48 hours
      },
      {
        headers: {
          Authorization: `Bearer ${TELCO_API_KEY}`,
          'Content-Type': 'application/json',
        },
        timeout: 2500, // Strict 2.5s timeout for gateway calls
      }
    );

    const result: SimSwapResponse = {
      swapped: response.data.swapped,
      lastSwappedAt: response.data.lastSwappedAt,
    };

    await redis.setex(cacheKey, 900, JSON.stringify(result));
    return result;
  } catch (error: any) {
    // Fail open or closed based on risk appetite. For financial transfers > ₦500,000, fail closed.
    console.error('Telco SIM-Swap API error:', error.message);
    return { swapped: false }; 
  }
}

Step 3: Hardening Money Transfers with Hardware Assertion & SIM Checks

Now, tie both layers together in your payment execution middleware. Before transferring funds out of a Paystack Dedicated Virtual Account or Monnify NUBAN, the server must:

  1. Check the user's SIM swap status.
  2. Require a WebAuthn biometric assertion signature matching the payload parameters.
// src/controllers/transferController.ts
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
import { Request, Response } from 'express';
import { checkSimSwapStatus } from '../services/simSwapCheck';
import { db } from '../db';
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL!);
const RP_ID = 'api.neobotpay.ng';

export async function executeTransfer(req: Request, res: Response) {
  const { amount, recipientNuban, bankCode, authResponse } = req.body;
  const userId = req.user.id;
  const userPhone = req.user.phoneNumber;

  // Layer 1: Check SIM Swap status
  const simStatus = await checkSimSwapStatus(userPhone);
  if (simStatus.swapped) {
    // Audit log security incident
    await db.query(
      'INSERT INTO security_audit_logs (user_id, event, metadata) VALUES ($1, $2, $3)',
      [userId, 'SIM_SWAP_BLOCKED_TRANSFER', JSON.stringify({ amount, recipientNuban })]
    );

    return res.status(403).json({
      error: 'SECURITY_RESTRICTION',
      message: 'A recent SIM card change was detected on your line. Transactions are restricted for 48 hours. Please contact customer support.',
    });
  }

  // Layer 2: Fetch stored public key credential
  const credentialQuery = await db.query(
    'SELECT * FROM user_credentials WHERE user_id = $1 AND credential_id = $2',
    [userId, authResponse.id]
  );

  if (credentialQuery.rows.length === 0) {
    return res.status(401).json({ error: 'Unrecognized hardware device' });
  }

  const dbCredential = credentialQuery.rows[0];
  const expectedChallenge = await redis.get(`transfer_challenge:${userId}`);

  if (!expectedChallenge) {
    return res.status(400).json({ error: 'Transfer session expired. Please retry.' });
  }

  // Layer 3: Cryptographically verify hardware signature
  let verification;
  try {
    verification = await verifyAuthenticationResponse({
      response: authResponse,
      expectedChallenge,
      expectedOrigin: 'https://api.neobotpay.ng',
      expectedRPID: RP_ID,
      authenticator: {
        credentialID: dbCredential.credential_id,
        credentialPublicKey: Buffer.from(dbCredential.public_key),
        counter: dbCredential.counter,
      },
    });
  } catch (error: any) {
    return res.status(400).json({ error: `Authentication failed: ${error.message}` });
  }

  if (!verification.verified) {
    return res.status(401).json({ error: 'Invalid biometric signature' });
  }

  // Update counter to prevent replay attacks
  await db.query(
    'UPDATE user_credentials SET counter = $1, last_used_at = NOW() WHERE credential_id = $2',
    [verification.authenticationInfo.newCounter, dbCredential.credential_id]
  );

  // Clean challenge
  await redis.del(`transfer_challenge:${userId}`);

  // Step 4: Dispatch payment to core banking infrastructure
  // Handle concurrent locks as detailed in our guide on Postgres Advisory Locks vs Redis Redlock
  const transferResult = await processCoreBankingTransfer({
    userId,
    amount,
    recipientNuban,
    bankCode,
  });

  return res.json({
    status: 'success',
    transactionRef: transferResult.reference,
  });
}

Comparison: Authentication Strategies for Nigerian Mobile Apps

When designing auth architecture, review how traditional factors perform under local fraud conditions:

| Feature / Metric | SMS OTP | TOTP (Authy / Google Authenticator) | WebAuthn Device Binding + SIM Check | | :--- | :--- | :--- | :--- | | Protection against SIM Swaps | ❌ None (Primary target) | ✅ High | ✅ Absolute (Hardware-bound) | | Protection against Phishing / MITM | ❌ None | ⚠️ Partial | ✅ Absolute (Domain-bound origin checks) | | Network Dependency | ❌ Requires Telco Delivery | ✅ Offline capable | ✅ Offline challenge generation | | User Friction | Moderate (Wait for SMS) | High (App switching) | Low (Touch ID / Face ID / PIN) | | Cost per Authentication | ₦3 - ₦5 per SMS | ₦0 | ~₦0 (Cached Telco checks < ₦0.50) | | NDPR / Regulatory Alignment | Poor (Insecure channel) | Adequate | Excellent (FIDO2 Compliant) |

Refer to the OWASP MFA Cheat Sheet for detailed guidelines on authenticators and session binding standards.


Common Pitfalls in Nigerian Mobile Security Architecture

1. Failing to Implement High-Availability Fallbacks for Telco Gateways

Telco APIs in West Africa experience periodic latency spikes and service outages. If your SIM check endpoint hangs, your app's payment flow will freeze. Set a maximum strict timeout of 2,500ms on telco HTTP calls. If the API times out, fallback to a step-up biometric liveness check rather than outright failing or completely bypassing security.

2. Relying on Client-Side Device Fingerprinting Alone

JavaScript or React Native device fingerprinting libraries that query screen resolution, platform specs, or CPU cores are easily spoofed using emulator hooks (e.g., Frida, Xposed framework). Always validate cryptographic signatures generated directly by the hardware Security Enclave / TEE on the server side.

3. Ignoring Authenticator Counter Replay Protection

WebAuthn credentials return a signature counter that increments with every authentication event. If a client sends an assertion request with a counter equal to or lower than the stored value, an attacker is replaying a previously intercepted signature. Always reject non-incrementing counters immediately.

4. Overlooking Concurrent Payment Race Conditions

Once hardware verification succeeds, engineers often forget that double-spend race conditions remain possible. Always enforce pessimistic database locking on wallet operations, as discussed in Postgres Advisory Locks vs Redis Redlock.


Frequently Asked Questions

Is SMS OTP explicitly banned by CBN or NDPR regulations?

The Central Bank of Nigeria (CBN) regulatory framework for PSBs and Commercial Banks discourages single-factor SMS authentication for high-value transactions above ₦50,000, recommending two-factor or tokenized controls. While SMS OTP is not strictly illegal, relying on it as the sole factor leaves operators vulnerable to negligence liability under NDPR data protection rules if user accounts are compromised via known SIM swap vectors.

What happens when a user legitimately upgrades their phone?

When a user buys a new device, they must complete an out-of-band identity re-verification. Require the user to record a selfie with active biometric liveness detection cross-referenced against their NIMC/BVN record, or use an admin recovery workflow. Once verified, the backend revokes the old public key credential and registers the new device's Secure Enclave key.

How do low-end Android smartphones handle WebAuthn?

Over 90% of budget Android devices running Android 8.0+ in West Africa (including Tecno, Infinix, and Itel) ship with Google Play Services and hardware-backed keystores. On devices lacking physical fingerprint sensors, WebAuthn falls back seamlessly to the user's OS screen lock (PIN, Pattern, or Password) via FIDO2 client specs, preserving hardware security without requiring expensive biometrics.

Neobot Engineering Standard

Every system deployed by Neobot Tech incorporates enterprise baseline practices. We continuously audit our database topologies, REST API query paths, and frontend modular bundles to prevent latency spikes and ensure top-tier security posture.

Tags:#Security#Nodejs#WebAuthn#Authentication#Fintech#NDPR#Fraud Prevention

Discussion

Comments Coming Soon

We are currently migrating our discussion engine to a new real-time database schema. Check back shortly to join the conversation.