Securing payment endpoints: patterns, pitfalls and fixes
Payment systems sit at the crossroads of high value and high risk. Once a checkout flow goes live, attackers will probe it—testing card validity, brute-forcing authentication, replaying requests, and injecting invalid amounts. Securing payment endpoints requires deliberate patterns, awareness of common pitfalls, and practical fixes we can implement today.
Why payment endpoints are a target
Payment endpoints touch money. Unlike other business logic that fails gracefully or costs time to debug, payment security failures can mean unauthorized charges, stolen card data, compliance violations, and customer harm within minutes. Attackers automate probes against checkout APIs looking for:
- Missing rate limiting (card testing, account enumeration)
- Weak authentication (lateral movement between accounts)
- Client-side validation (amount manipulation)
- Lack of idempotency (duplicate charges)
- Missing fraud signals (unusual patterns flying under the radar)
We've seen each of these in production. The fix isn't complex, but it requires discipline across the team.
Rate-limit checkout endpoints
Rate limiting is your first line of defense. Without it, attackers can test thousands of card numbers, enumerate valid email addresses, or trigger expensive operations at scale. Checkout endpoints should be rate-limited both globally and per-user.
Per-user rate limiting prevents a single account from hammering your payment API. Global rate limiting catches distributed attacks or compromised tokens. We typically enforce both:
- 10 payment attempts per user per minute (allows legitimate retries on network failure)
- 100 payment attempts per IP per hour (catches botnets)
- 1000 payments globally per minute (circuit breaker for infrastructure)
In NestJS with Redis, we can implement this with the @nestjs/throttler module:
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
import { ThrottlerGuard, Throttle } from '@nestjs/throttler';
@Controller('payments')
@UseGuards(ThrottlerGuard)
export class PaymentController {
@Post('checkout')
@Throttle({ default: { limit: 10, ttl: 60000 } })
async checkout(@Body() body: CheckoutDto) {
// Process payment
}
}
For more granular control, we also rate-limit by user ID and IP separately:
import { Injectable } from '@nestjs/common';
import * as Redis from 'redis';
@Injectable()
export class RateLimitService {
private client = Redis.createClient();
async checkLimit(userId: string, ip: string): Promise {
const userKey = `rate:checkout:user:${userId}`;
const ipKey = `rate:checkout:ip:${ip}`;
const [userCount, ipCount] = await Promise.all([
this.client.incr(userKey),
this.client.incr(ipKey),
]);
// Set expiry on first hit
if (userCount === 1) {
await this.client.expire(userKey, 60); // 60 seconds
}
if (ipCount === 1) {
await this.client.expire(ipKey, 3600); // 1 hour
}
return userCount <= 10 && ipCount <= 100;
}
}
Combine this with circuit breakers for total volume. If you see 1000 payment attempts in one minute, something is wrong—trigger an alert and reject new requests until an engineer reviews.
Authenticate users before saving cards
Card tokenization endpoints are prime targets for account takeover. An attacker with a valid session token but no payment intent should never be able to add a card to another user's account.
Always validate ownership: Before allowing a card save or update, verify the user can modify that account. This means checking the JWT or session token maps to the user ID in the request path or body. Use strong, role-based access control (RBAC):
@Post('users/:userId/cards')
async saveCard(
@Param('userId') userId: string,
@Body() body: SaveCardDto,
@Request() req: any,
) {
// Verify the authenticated user owns this account
if (req.user.id !== userId && req.user.role !== 'admin') {
throw new ForbiddenException('Cannot modify other users.');
}
// Proceed with Stripe or payment processor
const token = await stripe.tokens.create({
card: body.card,
});
await this.db.cards.create({
userId,
stripeTokenId: token.id,
last4: token.card.last4,
});
}
Also enforce multi-factor authentication (MFA) for sensitive card operations: If a user is adding a card for the first time or changing the default payment method, require MFA—SMS, email link, or app-based. This stops account compromise from moving money immediately.
One more pattern: require the card's CVV every time for high-risk operations. The CVV is not stored in your database (by design, PCI rules forbid it), so it can't be replayed from a compromised database. If an attacker steals a database backup, they still can't charge cards without the CVV.
Validate amounts server-side
Never trust the amount sent from the client. The most common breach we see is an attacker intercepting a JavaScript checkout request and changing the amount from $99 to $0.01 or $9.
The fix is straightforward: fetch the order total on the server, verify it matches the request, and charge only the verified amount:
@Post('checkout')
async checkout(
@Body() body: CheckoutDto,
@Request() req: any,
) {
const userId = req.user.id;
// Fetch order from database
const order = await this.db.orders.findById(body.orderId);
if (!order || order.userId !== userId) {
throw new BadRequestException('Order not found.');
}
// Verify amount hasn't changed
if (order.totalCents !== body.amountCents) {
this.logger.warn(
`Amount mismatch for order ${body.orderId}: expected ${order.totalCents}, got ${body.amountCents}`,
);
throw new BadRequestException('Amount validation failed.');
}
// Process payment with verified amount
const intent = await stripe.paymentIntents.create({
amount: order.totalCents,
currency: 'usd',
customer: order.stripeCustomerId,
});
return { clientSecret: intent.client_secret };
}
Also validate currency. If a user's account is in USD but the request specifies EUR, that's a red flag. Log it, block it, and investigate.
Monitor unusual patterns
Even with rate limiting, authentication, and validation in place, sophisticated attackers will slip through. Behavioral monitoring catches what rule-based defenses miss.
Common patterns to alert on:
- Multiple failed payment attempts followed by success (card testing)
- Payment from a new device or geolocation immediately after account creation (account takeover)
- Rapid card additions with no actual purchases (tokenization for later fraud)
- Payments to new recipients or unusual recipients (payment diversion)
- Orders with high discount or refund rates (friendly fraud or abuse)
Implement a simple anomaly score in your database layer:
@Injectable()
export class FraudService {
async scorePayment(payment: PaymentEvent): Promise {
let score = 0;
// Check for velocity: multiple attempts in short window
const recentAttempts = await this.db.payments.countRecent({
userId: payment.userId,
status: 'failed',
since: 5 * 60 * 1000, // 5 minutes
});
if (recentAttempts >= 3) score += 30;
// Check for new device
const userDevices = await this.db.userDevices.findByUserId(payment.userId);
if (!userDevices.some(d => d.fingerprint === payment.deviceFingerprint)) {
score += 20;
}
// Check for new geolocation (rough: country level)
const userCountries = await this.db.userLocations.distinctCountries(payment.userId);
if (!userCountries.includes(payment.country)) {
score += 15;
}
// Check for refund rate
const refundRate = await this.db.analytics.userRefundRate(payment.userId);
if (refundRate > 0.3) score += 25; // 30% refund rate is high
return score;
}
async handlePayment(payment: PaymentEvent): Promise {
const score = await this.scorePayment(payment);
if (score >= 60) {
// Require additional verification (3D Secure, challenge)
await this.requireChallenge(payment);
} else if (score >= 80) {
// Decline and investigate
await this.declineAndAlert(payment);
}
}
}
Alert on these scores in real-time. Integrate with Stripe's radar or a third-party fraud provider for battle-tested models, but build this layer anyway so you own your data and can act before charges settle.
Common mistakes
Mistake 1: Rate limiting only on successful payments. Attackers will test thousands of cards with intentionally wrong CVVs or expiry dates. Rate limit all attempts, not just successes.
Mistake 2: Validating amounts on the client only. If your backend doesn't re-verify the order total, you're vulnerable. Always assume the client is compromised.
Mistake 3: Relying on session tokens alone. A leaked or replayed token can be used to modify another user's payment method. Add RBAC and ownership checks on every endpoint.
Mistake 4: Logging full card numbers or even last4 without encryption. PCI compliance forbids it. If your logs are breached, you've exposed payment data. Log encrypted identifiers only.
Mistake 5: No monitoring because "Stripe handles fraud." Stripe's fraud detection is good, but it's reactive. Your own monitoring catches abuse earlier and reduces chargebacks.
Mistake 6: Inconsistent idempotency keys. Without idempotency, a network timeout can lead to duplicate charges. Always include and validate a unique key per request.
const idempotencyKey = `${userId}:${orderId}:${Date.now()}`;
const intent = await stripe.paymentIntents.create(
{
amount: order.totalCents,
currency: 'usd',
customer: order.stripeCustomerId,
},
{
idempotencyKey, // Stripe will deduplicate if called twice
},
);
Key takeaways
- Rate limit all payment endpoints—per user, per IP, and globally—to stop automated attacks.
- Authenticate users and verify account ownership before any card operation; add MFA for high-risk changes.
- Validate amounts and currency on the server; never trust the client.
- Monitor behavioral anomalies (velocity, new devices, refund rates) and act before fraud settles.
- Use idempotency keys to prevent duplicate charges on retries.
- Log encrypted identifiers only; never store or log full card data outside PCI-compliant vaults.
- Combine rule-based defenses with third-party fraud detection; use both to raise your bar.






Comments
Loading comments…