Abuse of Public Endpoints — Protecting Your Free Tier From Exploitation
Advertisement
Introduction
Why This Matters
If your endpoint does something computationally valuable — generate an image, send an email, convert a PDF, run inference — someone will automate it and use it for free. The abuse ranges from benign overuse (one account consuming 10,000 AI generations per day on the free tier) to malicious exploitation (using your email endpoint as a spam relay or your OTP endpoint for SMS pumping fraud).
The controls are not complicated, but they must be intentional: per-account quotas, cost attribution, anomaly detection, and abuse patterns that make exploitation economically unattractive.
Common Endpoint Abuse Patterns
Understanding attack vectors helps you build the right controls:
| Endpoint Type | Abuse Pattern | Business Impact |
|---|---|---|
| AI/ML inference | One account generates 50,000 images at $0.002 each | $100/day in GPU costs |
| Email sending | Used as a transactional spam relay | Domain blacklisted, deliverability destroyed |
| SMS OTP | SMS pumping — trigger OTPs to premium numbers | Carrier bills spike to thousands per day |
| File conversion | Free PDF/image processing at scale | CPU and memory bills accumulate |
| Search | Paginating through entire content catalog | Database load, IP scraping |
| Auth endpoints | Credential stuffing attacks | Account takeovers, security breach |
Fix 1: Per-Account Quotas With Hard Limits
Every resource must have a hard daily quota enforced at the middleware level — before any processing begins:
// quota-manager.ts
import { redis } from './redis'
import { db } from './database'
interface QuotaResult {
allowed: boolean
remaining: number
resetAt: Date
used: number
limit: number
}
const DEFAULT_FREE_TIER_QUOTAS: Record<string, { limit: number; window: 'hourly' | 'daily' | 'monthly' }> = {
ai_generations: { limit: 50, window: 'daily' },
email_sends: { limit: 100, window: 'daily' },
sms_sends: { limit: 20, window: 'daily' },
pdf_conversions: { limit: 10, window: 'daily' },
api_calls: { limit: 1000, window: 'hourly' },
}
const WINDOW_TTL: Record<string, number> = {
hourly: 3600,
daily: 86400,
monthly: 2592000,
}
export async function checkAndIncrementQuota(
accountId: string,
resource: string,
cost = 1
): Promise<QuotaResult> {
// Get quota from account plan (Pro, Free, etc.) or use default
const quota = await getAccountQuota(accountId, resource)
const ttl = WINDOW_TTL[quota.window]
const resetAt = new Date(Date.now() + ttl * 1000)
const key = `quota:${accountId}:${resource}:${getCurrentWindowKey(quota.window)}`
// Lua script for atomic check-and-increment
const luaScript = `
local current = redis.call('GET', KEYS[1])
local count = tonumber(current) or 0
if count + tonumber(ARGV[1]) > tonumber(ARGV[2]) then
return {count, 0}
end
local new_count = redis.call('INCRBY', KEYS[1], ARGV[1])
if new_count == tonumber(ARGV[1]) then
redis.call('EXPIRE', KEYS[1], ARGV[3])
end
return {new_count, 1}
`
const [used, allowed] = await redis.eval(
luaScript,
1,
key,
cost.toString(),
quota.limit.toString(),
ttl.toString()
) as [number, number]
return {
allowed: allowed === 1,
remaining: Math.max(0, quota.limit - used),
resetAt,
used,
limit: quota.limit,
}
}
async function getAccountQuota(
accountId: string,
resource: string
): Promise<{ limit: number; window: 'hourly' | 'daily' | 'monthly' }> {
const account = await db.accounts.findUnique({ where: { id: accountId } })
if (account?.plan === 'pro') {
return { limit: 10_000, window: 'daily' }
}
return DEFAULT_FREE_TIER_QUOTAS[resource] ?? { limit: 100, window: 'daily' }
}
function getCurrentWindowKey(window: string): string {
const now = new Date()
if (window === 'hourly') return `${now.toISOString().slice(0, 13)}`
if (window === 'daily') return `${now.toISOString().slice(0, 10)}`
return `${now.getFullYear()}-${now.getMonth() + 1}`
}
// Express middleware
export function quotaMiddleware(resource: string, cost = 1) {
return async (req: any, res: any, next: any) => {
const accountId = req.account.id
const result = await checkAndIncrementQuota(accountId, resource, cost)
res.set({
'X-RateLimit-Resource': resource,
'X-RateLimit-Limit': result.limit,
'X-RateLimit-Remaining': result.remaining,
'X-RateLimit-Reset': result.resetAt.toISOString(),
})
if (!result.allowed) {
return res.status(429).json({
error: `Daily ${resource} quota exceeded`,
limit: result.limit,
used: result.used,
resetAt: result.resetAt.toISOString(),
upgradeUrl: 'https://myapp.com/pricing',
})
}
next()
}
}Fix 2: SMS Pumping Fraud Prevention
SMS pumping fraud is when attackers trigger OTP requests to premium-rate phone numbers. The carrier pays the attacker a revenue share, and your Twilio bill spikes:
// sms-validator.ts
import { parsePhoneNumber, isValidPhoneNumber } from 'libphonenumber-js'
const PREMIUM_NUMBER_PREFIXES: Record<string, string[]> = {
US: ['+1900', '+1976'],
GB: ['+4490', '+4491'],
DE: ['+49900'],
}
// Countries with elevated SMS pumping risk
const HIGH_RISK_COUNTRIES = new Set(['KE', 'NG', 'PK', 'BD', 'MZ', 'GH', 'TZ'])
export async function validatePhoneForOTP(
phoneNumber: string,
accountId: string,
redis: any
): Promise<void> {
// 1. Format validation
if (!isValidPhoneNumber(phoneNumber)) {
throw new Error('Invalid phone number format')
}
const parsed = parsePhoneNumber(phoneNumber)
const country = parsed.country ?? 'XX'
// 2. Block premium-rate numbers
const premiumPrefixes = PREMIUM_NUMBER_PREFIXES[country] ?? []
if (premiumPrefixes.some(prefix => phoneNumber.startsWith(prefix))) {
throw new Error('Phone number not eligible for OTP delivery')
}
// 3. Rate limit per phone number globally (not just per account)
// This prevents one attacker creating many accounts to bypass per-account limits
const phoneKey = `sms_attempts:${phoneNumber}`
const attempts = await redis.incr(phoneKey)
if (attempts === 1) await redis.expire(phoneKey, 3600)
if (attempts > 5) {
throw new Error('Too many OTP requests to this number. Please wait before retrying.')
}
// 4. Enhanced controls for high-risk countries
if (HIGH_RISK_COUNTRIES.has(country)) {
const accountKey = `sms_highrisk:${accountId}`
const accountAttempts = await redis.incr(accountKey)
if (accountAttempts === 1) await redis.expire(accountKey, 86400)
if (accountAttempts > 10) {
await flagAccountForReview(accountId, 'high_risk_sms_volume', {
country,
dailyAttempts: accountAttempts,
})
throw new Error('Account flagged for SMS volume review. Contact support.')
}
}
}
async function flagAccountForReview(
accountId: string,
reason: string,
context: object
): Promise<void> {
// Store flag in DB and notify ops team
console.warn({ accountId, reason, context }, 'Account flagged for review')
}Fix 3: AI/ML Compute Abuse Prevention
// ai-endpoint-protection.ts
import express from 'express'
import { quotaMiddleware } from './quota-manager'
const router = express.Router()
router.post(
'/api/generate-image',
requireAuth,
quotaMiddleware('ai_generations', 1),
async (req, res) => {
const { prompt, style } = req.body
// 1. Input validation — reject unusually long prompts (bulk automation pattern)
if (!prompt || typeof prompt !== 'string') {
return res.status(400).json({ error: 'prompt is required' })
}
if (prompt.length > 500) {
return res.status(400).json({ error: 'Prompt exceeds maximum length of 500 characters' })
}
// 2. Hourly velocity check — flag accounts with bot-like patterns
const recentGenerations = await db.query(
`SELECT COUNT(*) as count
FROM ai_generations
WHERE account_id = $1
AND created_at > NOW() - INTERVAL '1 hour'`,
[req.account.id]
)
if (parseInt(recentGenerations.rows[0].count) > 100) {
await flagAccountForReview(req.account.id, 'ai_generation_velocity', {
hourlyCount: recentGenerations.rows[0].count,
})
return res.status(429).json({
error: 'Unusual generation activity detected. Account temporarily paused.',
message: 'Contact support@myapp.com if you believe this is an error.',
})
}
// 3. Queue with priority based on plan tier
const priority = req.account.plan === 'pro' ? 10 : 1
const job = await imageQueue.add(
'generate',
{ prompt, style, accountId: req.account.id },
{
priority,
timeout: 60_000,
attempts: 2,
}
)
// 4. Track usage for cost attribution
await db.query(
`INSERT INTO ai_generations (account_id, job_id, prompt_length, plan)
VALUES ($1, $2, $3, $4)`,
[req.account.id, job.id, prompt.length, req.account.plan]
)
res.json({
jobId: job.id,
estimatedWaitSeconds: await getEstimatedWait(priority),
})
}
)Fix 4: Real-Time Anomaly Detection
A cron job that runs every 5 minutes catches abuse before it becomes a large bill:
// abuse-detector.ts
import cron from 'node-cron'
// Run every 5 minutes
cron.schedule('*/5 * * * *', detectAbusePatterns)
async function detectAbusePatterns(): Promise<void> {
// Find accounts consuming more than 10x their rolling average
const suspiciousAccounts = await db.query(`
SELECT
account_id,
SUM(cost_units) AS usage_last_hour,
AVG(avg_hourly) AS typical_hourly
FROM (
SELECT
account_id,
cost_units,
AVG(cost_units) OVER (
PARTITION BY account_id
ORDER BY created_at
ROWS BETWEEN 168 PRECEDING AND 1 PRECEDING -- last 7 days hourly
) AS avg_hourly
FROM api_usage_hourly
WHERE created_at > NOW() - INTERVAL '1 hour'
) sub
GROUP BY account_id
HAVING SUM(cost_units) > COALESCE(AVG(avg_hourly) * 10, 100)
ORDER BY usage_last_hour DESC
LIMIT 20
`)
for (const account of suspiciousAccounts.rows) {
console.warn({
accountId: account.account_id,
usageLastHour: account.usage_last_hour,
typicalHourly: account.typical_hourly,
multiplier: account.usage_last_hour / (account.typical_hourly || 1),
}, 'Anomalous usage detected')
await alertOpsTeam({
title: `Potential abuse: ${account.account_id}`,
body: `Used ${account.usage_last_hour} units in last hour (typical: ${account.typical_hourly}/hour)`,
severity: 'warning',
})
// Auto-suspend if the spike is extreme (100x normal)
if (account.usage_last_hour > (account.typical_hourly || 1) * 100) {
await suspendAccount(account.account_id, 'automated_abuse_detection')
}
}
}
async function suspendAccount(accountId: string, reason: string): Promise<void> {
await db.query(
`UPDATE accounts SET status = 'suspended', suspended_reason = $2, suspended_at = NOW()
WHERE id = $1`,
[accountId, reason]
)
// Invalidate all active sessions/tokens for this account
await redis.del(`account:${accountId}:active`)
console.log({ accountId, reason }, 'Account suspended')
}Common Mistakes
- Setting rate limits per IP only — attackers rotate IPs; always limit per account as well
- Using soft quotas that warn but don't block — abusers ignore warnings
- Missing cross-account limits for SMS (same phone number, multiple accounts)
- Anomaly detection that runs hourly — by then $500 in damage may already be done
- Flagging accounts but not suspending them — abusers continue until manually reviewed
- Not attributing costs per account — impossible to identify who is causing the bill
Best Practices
- Enforce quotas at the middleware layer before any computation begins — never at the business logic layer
- Use atomic Redis operations (Lua scripts or
INCRwithEXPIRE) to prevent race conditions in quota checks - Run anomaly detection every 5 minutes — bills compound fast on GPU endpoints
- Attribute all compute costs to accounts so you can identify the top consumers at any time
- Auto-suspend accounts that spike more than 100x their normal usage — send a notice and require support contact
- Track SMS attempts globally per phone number, not just per account, to prevent SMS pumping across multiple free accounts
Key Takeaways
- Every endpoint with real compute value will be systematically abused — plan for it before launch, not after the first bill
- Per-account quotas enforced atomically with Redis are the foundation of all abuse prevention
- SMS pumping fraud is expensive and detectable — validate phone numbers against premium prefixes and rate limit per phone number globally across all accounts
- Anomaly detection comparing current usage to rolling average (not fixed thresholds) catches new accounts launching automated abuse
- Cost attribution per account makes it trivial to identify who is responsible for usage spikes
- Auto-suspension with a support escalation path is more effective than just rate limiting — abusers push through rate limits
- The goal is economic deterrence: make abuse more expensive to run than legitimate usage provides value
Advertisement