Rate Limiting Your Own Payment Endpoints
Apply rate limiting to every payment-related endpoint. Use express-rate-limit or a Redis-backed limiter. Set strict limits on initialization endpoints (5-10 requests per minute per IP) and generous limits on webhook endpoints (100+ per minute from Paystack IPs). Rate limit by IP, by user, and by business rules like maximum transactions per customer per day.
Why Rate Limiting Payment Endpoints Matters
Paystack has its own API rate limits to protect their infrastructure. But your payment endpoints need their own rate limiting for a different reason: to protect your business from abuse.
Without rate limiting, attackers can:
- Card testing. Test hundreds of stolen card numbers per minute against your checkout to find which cards are valid. Each test is a small transaction (100 kobo) that gets reversed, but you pay processing fees for each one.
- Brute force verification. Guess transaction references and call your verification endpoint repeatedly, looking for a successful transaction they can claim.
- Denial of service. Flood your webhook endpoint with fake payloads, causing your server to spend all its resources on signature verification.
- Transaction reference enumeration. Try sequential references to discover valid transactions and their details.
Rate limiting does not replace other security measures (signature verification, amount validation, idempotency). It is a layer of defense that reduces the speed and scale of attacks.
Basic Rate Limiting with Express
The simplest approach uses the express-rate-limit package:
var rateLimit = require('express-rate-limit');
// Strict limit for payment initialization
var paymentInitLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 5, // 5 requests per minute per IP
standardHeaders: true,
legacyHeaders: false,
message: {
status: 429,
error: 'Too many payment attempts. Please wait a minute and try again.',
},
});
// Moderate limit for verification
var verifyLimiter = rateLimit({
windowMs: 60 * 1000,
max: 20,
standardHeaders: true,
legacyHeaders: false,
message: {
status: 429,
error: 'Too many verification requests. Please wait.',
},
});
// Generous limit for webhooks (Paystack retries)
var webhookLimiter = rateLimit({
windowMs: 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
});
app.post('/api/payment/initialize', paymentInitLimiter, handleInitialize);
app.get('/api/payment/verify', verifyLimiter, handleVerify);
app.post('/webhook/paystack', webhookLimiter, handleWebhook);
This in-memory approach works for a single server process. For production with multiple server instances, you need a shared store like Redis.
Redis-Backed Rate Limiting for Production
In-memory rate limiters have two problems: they reset when the server restarts, and they do not share state across multiple server instances. A Redis-backed limiter solves both.
var rateLimit = require('express-rate-limit');
var RedisStore = require('rate-limit-redis');
var Redis = require('ioredis');
var redis = new Redis(process.env.REDIS_URL);
var paymentInitLimiter = rateLimit({
store: new RedisStore({
sendCommand: function() {
return redis.call.apply(redis, arguments);
},
}),
windowMs: 60 * 1000,
max: 5,
standardHeaders: true,
keyGenerator: function(req) {
// Rate limit by IP
return 'ratelimit:init:' + req.ip;
},
});
var userPaymentLimiter = rateLimit({
store: new RedisStore({
sendCommand: function() {
return redis.call.apply(redis, arguments);
},
}),
windowMs: 24 * 60 * 60 * 1000, // 24 hours
max: 20, // 20 transactions per day per user
standardHeaders: true,
keyGenerator: function(req) {
// Rate limit by authenticated user
return 'ratelimit:user:' + (req.user ? req.user.id : req.ip);
},
});
app.post('/api/payment/initialize', paymentInitLimiter, userPaymentLimiter, handleInitialize);
Notice the two limiters stacked on the initialization endpoint: one limits by IP (5 per minute) and one limits by user (20 per day). An attacker using a single IP cannot exceed 5 attempts per minute. An authenticated user cannot exceed 20 transactions per day, regardless of how many IPs they use.
Rate Limiting Strategies by Endpoint
Payment Initialization
This is the endpoint attackers target most for card testing. Apply the strictest limits:
- 5-10 requests per minute per IP
- 20 requests per day per authenticated user
- Require authentication before allowing initialization
Transaction Verification
Moderate limits. Legitimate users may verify the same transaction multiple times (page refreshes, retries):
- 20 requests per minute per IP
- Only accept references that match orders in your database (reject random reference guesses)
Webhook Endpoint
Be generous here. Paystack sends legitimate retries, and during busy periods you may receive many webhooks in quick succession:
- 100-200 requests per minute total (not per IP, since all come from Paystack)
- Combine with IP allowlisting for Paystack's webhook IPs
- Signature verification is your primary defense here, not rate limiting
Refund and Transfer Endpoints (Admin)
Very strict limits. These endpoints move money:
- 3-5 requests per minute per admin user
- Require multi-factor authentication for each action
- Log every request with the admin user, IP, and action details
Business Rule Rate Limiting
Beyond simple request-per-minute limits, implement business-rule-based limiting that catches attacks that stay under the per-minute threshold:
async function checkBusinessRules(req) {
var email = req.body.email;
var ip = req.ip;
// Rule 1: Max 3 failed payments per email per hour
var recentFailures = await db.query(
'SELECT COUNT(*) FROM payment_attempts WHERE email = $1 AND status = $2 AND created_at > NOW() - INTERVAL '1 hour'',
[email, 'failed']
);
if (parseInt(recentFailures.rows[0].count) >= 3) {
return { blocked: true, reason: 'Too many failed payment attempts' };
}
// Rule 2: Max 5 different emails from the same IP per hour
var ipEmails = await db.query(
'SELECT COUNT(DISTINCT email) FROM payment_attempts WHERE ip_address = $1 AND created_at > NOW() - INTERVAL '1 hour'',
[ip]
);
if (parseInt(ipEmails.rows[0].count) >= 5) {
return { blocked: true, reason: 'Too many different emails from this IP' };
}
// Rule 3: Max transaction amount per day per user
var dailyTotal = await db.query(
'SELECT COALESCE(SUM(amount), 0) as total FROM successful_payments WHERE user_id = $1 AND created_at > NOW() - INTERVAL '24 hours'',
[req.user.id]
);
if (parseInt(dailyTotal.rows[0].total) > 10000000) { // 100,000 NGN daily limit
return { blocked: true, reason: 'Daily transaction limit reached' };
}
return { blocked: false };
}
app.post('/api/payment/initialize', paymentInitLimiter, async function(req, res) {
var ruleCheck = await checkBusinessRules(req);
if (ruleCheck.blocked) {
return res.status(429).json({ error: ruleCheck.reason });
}
// Record the attempt
await db.query(
'INSERT INTO payment_attempts (email, ip_address, amount, status) VALUES ($1, $2, $3, $4)',
[req.body.email, req.ip, req.body.amount, 'attempted']
);
// Proceed with initialization
await handleInitialize(req, res);
});
Business rules catch sophisticated attacks that rotate IPs or use botnets to stay under per-IP rate limits. The rules above catch card testing (many failures), distributed attacks (many emails from one IP), and high-value fraud (daily limits).
Response Best Practices for Rate-Limited Requests
How you respond to rate-limited requests matters for both security and user experience:
- Return 429 (Too Many Requests). This is the correct HTTP status code. Do not return 200 (confuses the client), 500 (implies a server error), or 403 (implies authentication issues).
- Include a Retry-After header. Tell the client how many seconds to wait before trying again.
- Return a human-readable message. "Please wait a minute and try again" is better than a technical error code.
- Do not reveal the limit thresholds. Saying "You have exceeded the limit of 5 requests per minute" tells attackers exactly how fast they can go. Just say "Too many requests."
var rateLimitHandler = function(req, res) {
res.set('Retry-After', '60');
res.status(429).json({
status: false,
message: 'Too many requests. Please wait and try again.',
});
};
Monitoring Rate Limit Events
Every rate limit hit should be logged and monitored. A sudden increase in rate-limited requests means an attack is in progress.
var paymentInitLimiter = rateLimit({
windowMs: 60 * 1000,
max: 5,
handler: function(req, res) {
console.log(
'Rate limited: ' + req.ip +
' endpoint=' + req.path +
' user=' + (req.user ? req.user.id : 'anonymous')
);
// Increment a counter for alerting
incrementMetric('rate_limit_hits', {
endpoint: req.path,
ip: req.ip,
});
res.status(429).json({
status: false,
message: 'Too many requests. Please wait and try again.',
});
},
});
Set up alerts for thresholds like:
- More than 50 rate limit hits in 10 minutes (attack in progress)
- Rate limit hits from more than 10 unique IPs on the same endpoint in 5 minutes (distributed attack)
- Rate limit hits on the verification endpoint with nonexistent references (reference enumeration)
Key Takeaways
- ✓Rate limiting your payment endpoints is your first line of defense against card testing attacks, where attackers rapidly test stolen card numbers against your checkout.
- ✓Apply different rate limits to different endpoints: strict limits for initialization (5-10 per minute per IP), moderate for verification (20 per minute per IP), and generous for webhooks (100+ per minute).
- ✓Rate limit by multiple dimensions: IP address, authenticated user, and business rules like maximum transactions per customer per day.
- ✓Use Redis-backed rate limiters for production. In-memory limiters reset on server restart and do not work across multiple server instances.
- ✓Return 429 (Too Many Requests) with a Retry-After header. Do not return 500 or 200 for rate-limited requests.
- ✓Log rate limit hits for security monitoring. A sudden spike in rate-limited requests indicates an attack in progress.
Frequently Asked Questions
- Does Paystack have its own rate limits?
- Yes, Paystack rate limits API requests to protect their infrastructure. But their limits are designed for their infrastructure capacity, not for your business logic. You need your own limits to protect against card testing, abuse, and denial of service at your endpoint level.
- Will rate limiting affect legitimate customers?
- Not if you set reasonable limits. A legitimate customer rarely makes more than 2-3 payment attempts per minute. A limit of 5 per minute gives plenty of room for retries without allowing abuse. Monitor false positives and adjust thresholds based on your traffic patterns.
- Should I rate limit by IP behind a reverse proxy?
- Yes, but make sure you trust the correct header. Behind Nginx, Cloudflare, or a load balancer, the client IP is in X-Forwarded-For or X-Real-IP. Express trusts these headers only if you set app.set("trust proxy", true). Without this, all requests appear to come from the proxy IP.
- Can attackers bypass IP-based rate limiting?
- Yes, by rotating IPs through VPNs, proxies, or botnets. That is why you should combine IP-based limits with user-based limits and business rule limits. The more dimensions you rate limit on, the harder it is for an attacker to circumvent them.
- Should I block or slow down rate-limited requests?
- Return 429 immediately for most use cases. Deliberate slowing (adding a delay before responding) can tie up server resources. For webhook endpoints, return 429 quickly so Paystack knows to retry later without consuming your server resources.
Ready to build real-world apps?
Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.
Apply to the McTaba Marathon