Fraud prevention

Reduce SMS toll fraud with GCC phone validation

SMS pumping (artificially inflated traffic) abuses OTP and sign-up forms to generate paid SMS to numbers attackers profit from. Validating numbers before you send, restricting destinations, and rate-limiting sends removes most of that exposure.

1. Know the response

javascript
// GET /api/v1/phone/validate?phone=%2B971501234567 (scope: validation:phone)
{
"data": {
"phone": "+971501234567",
"valid": true,
"formats": { "e164": "+971501234567", "international": "...", "local": "..." },
"country": { "code": "AE", "name": "United Arab Emirates", "prefix": "+971", ... },
"type": "mobile", // "mobile" | "landline" | "unknown"
"nationalNumber": "501234567",
"carrier": { // present when the prefix maps to a known operator
"name": "e& (Etisalat)", "mcc": "424", "mnc": "02",
"type": "mno" // "mno" or "mvno" (e.g. Lebara on Zain in KSA)
},
"portingNote": "Carrier is based on original number allocation. Number may have been ported to a different carrier.",
"reason": "Valid phone number"
}
}

2. Gate every OTP send on the server

Send only to valid mobile numbers in countries you serve. For OTP flows, failing closed when validation is unavailable is usually the right trade-off; offer an alternative such as email verification instead.

javascript
// Server-side OTP gate (Node.js 20+). Never call KhaleejiAPI from the browser.
const KHALEEJI_API_KEY = process.env.KHALEEJI_API_KEY
const ALLOWED_COUNTRIES = new Set(["AE", "SA"]) // only countries you actually serve
export async function canSendOtp(rawPhone) {
const url = new URL("/api/v1/phone/validate", "https://khaleejiapi.dev")
url.searchParams.set("phone", rawPhone)
let data
try {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${KHALEEJI_API_KEY}` },
signal: AbortSignal.timeout(3000),
})
if (!res.ok) return { send: false, reason: "validation_unavailable" }
data = (await res.json()).data
} catch {
// For OTP, fail closed: a pumping attack is cheaper to stop than to refund.
return { send: false, reason: "validation_unavailable" }
}
if (!data?.valid) return { send: false, reason: "invalid_number" }
if (data.type !== "mobile") return { send: false, reason: "not_mobile" } // landlines cannot receive SMS
if (!ALLOWED_COUNTRIES.has(data.country.code)) return { send: false, reason: "country_not_served" }
return { send: true, e164: data.formats.e164, carrier: data.carrier?.name ?? null }
}

3. Rate-limit by number and by client

Validation cannot stop an attacker cycling through real, valid mobile numbers. Per-number and per-IP limits, plus a bot challenge on the form, close that gap.

javascript
// Per-number and per-IP limits stop repeated sends even to valid numbers.
// Example with any Redis-compatible store (ioredis API).
async function allowSend(redis, e164, ip) {
const [perNumber, perIp] = await Promise.all([
redis.incr(`otp:num:${e164}`),
redis.incr(`otp:ip:${ip}`),
])
await Promise.all([
redis.expire(`otp:num:${e164}`, 3600, "NX"),
redis.expire(`otp:ip:${ip}`, 3600, "NX"),
])
return perNumber <= 3 && perIp <= 10 // tune to your traffic
}

4. Use carrier data for routing and monitoring

  • Log carrier.name and country.code per send; a sudden spike to one operator or country is a common pumping signal.
  • If your SMS provider supports per-operator routes, carrier.mcc/carrier.mnc identify the original network. Treat it as a hint, because ported numbers keep their original prefix.
  • Alert on OTP sends that never complete verification; a high unverified ratio indicates abuse.

Related