How I Accidentally Left My E-Commerce Checkout Vulnerable to ₹1 Hacks (And Then Crashed Production)
How I Accidentally Left My E-Commerce Checkout Vulnerable to ₹1 Hacks (And Then Crashed Production)
Why trusting the frontend with checkout pricing and trusting cloud environments with SDKs will break your application.
Building PinnacleWear, a fully automated Print-on-Demand platform with React, Node.js, and MongoDB, was an incredible crash course in system design.
When you follow standard payment gateway tutorials (like Razorpay or Stripe), they teach you how to make things work quickly on localhost. However, the moment I moved my code toward a real-world production environment on Azure, I discovered two critical flaws in how developers usually integrate payment SDKs.
Here is how a malicious user could have bought a ₹2,000 hoodie for ₹1, and how a standard SDK initialization brought down my entire Azure production server.
Challenge 1: The ₹1 Checkout Vulnerability (Zero-Trust Architecture)
When integrating the Razorpay payment gateway, I initially built the checkout flow exactly how most front-end tutorials suggest:
- The React app calculates the total cart value.
- The React app passes that amount to the
window.Razorpayinitializer. - The user pays, and the signature is sent to the Node.js backend to fulfill the order.
The Fatal Flaw
I realized that technically, nothing stops a user from opening Chrome DevTools, putting a breakpoint in the JavaScript, and manually editing the amountInPaise variable to 100 (₹1).
If they did this, Razorpay would happily process a ₹1 payment and return a mathematically valid cryptographic signature. My backend would receive that signature, verify it against the secret key, mark the ₹2,000 order as "PAID", and automatically trigger my Printrove API to print and ship the product. I would lose thousands of rupees instantly.
The Fix: Server-Side Price Locking
I completely tore down the frontend integration and shifted to a Zero-Trust Architecture:
-
API Interception: When "Pay" is clicked, React calls a new backend endpoint:
POST /api/orders/create-razorpay-order. -
Database Source of Truth: The Node.js server loops through the user's cart, ignores the frontend prices entirely, and queries MongoDB for the true
basePriceof every SKU. -
Cryptographic Locking: The backend talks to Razorpay server-to-server and generates an
order_idlocked to the database's price. -
Double Verification: The frontend uses that
order_idto open the modal (the user cannot edit the price). Upon success, the backend double-verifies that the paid Razorpay Order amount exactly matches the backend-calculated amount before triggering fulfillment.
Takeaway: Never, ever trust client-side data when it involves financial transactions or supply chain automation.
Challenge 2: The Silent Azure Startup Crash (Defensive Coding)
With the payment logic secured, I pushed my code to GitHub. My CI/CD pipeline built the Docker container and deployed it to Azure App Service.
Immediately, the production environment crashed with Exit code: 1.
My application was completely offline. Users couldn't even view products or use Cash-on-Delivery.
The Fatal Flaw
The culprit was three lines of code at the top of my Express controller:
const razorpayInstance = new Razorpay({
key_id: process.env.RAZORPAY_KEY_ID,
key_secret: process.env.RAZORPAY_KEY_SECRET,
});
This is Top-Level Instantiation. Because my Azure environment variables were not perfectly synced with my local .env file yet, the key_id was passed as an empty string. The Razorpay SDK constructor immediately threw a fatal error.
Because this happened during the Node module loading phase (before app.listen() was even called), it crashed the entire Express server. One missing payment key took down my entire API, including routes that had nothing to do with payments.
The Fix: Fail-Safe Instantiation
I refactored the initialization to be defensive. External SDKs should be treated as unreliable dependencies.
let razorpayInstance = null;
try {
if (process.env.RAZORPAY_KEY_ID) {
razorpayInstance = new Razorpay({
key_id: process.env.RAZORPAY_KEY_ID,
key_secret: process.env.RAZORPAY_KEY_SECRET,
});
console.log('✅ Razorpay SDK initialized successfully');
} else {
console.warn('⚠️ Razorpay keys missing. App booted, but online payments disabled.');
}
} catch (error) {
console.error('❌ Failed to initialize Razorpay SDK', error);
}
Now, if a cloud environment is missing an API key, the server boots up normally and logs a graceful warning. I added middleware to specific checkout routes to return a clean 500 error asking the admin to configure the keys, keeping the rest of the application 100% online.
Takeaway: Your core application architecture should survive the failure or misconfiguration of third-party SDKs.
The Architecture: How It Works Now
Frontend Flow (React)
// 1. User adds items to cart
const cartItems = [
{ productId: 'prod_123', quantity: 2, size: 'M' },
{ productId: 'prod_456', quantity: 1, size: 'L' }
];
// 2. User clicks "Proceed to Payment"
const handleCheckout = async () => {
// Frontend does NOT calculate the price
// Frontend only sends product IDs and quantities
const response = await fetch('/api/orders/create-razorpay-order', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cartItems })
});
const { orderId, amount, currency } = await response.json();
// 3. Open Razorpay modal with server-generated order
const options = {
key: RAZORPAY_KEY_ID, // Public key (safe to expose)
amount: amount, // Server-calculated amount (user cannot modify)
currency: currency,
order_id: orderId, // Cryptographically locked order
handler: async (response) => {
// 4. Payment successful, verify on backend
await verifyPayment(response);
}
};
const razorpay = new window.Razorpay(options);
razorpay.open();
};
Backend Flow (Node.js)
// Route: POST /api/orders/create-razorpay-order
router.post('/create-razorpay-order', async (req, res) => {
try {
const { cartItems } = req.body;
// 1. Fetch true prices from database (NEVER trust frontend)
let totalAmount = 0;
const orderDetails = [];
for (const item of cartItems) {
const product = await Product.findById(item.productId);
if (!product) {
return res.status(400).json({ error: 'Invalid product' });
}
// Use database price, not frontend price
const itemTotal = product.basePrice * item.quantity;
totalAmount += itemTotal;
orderDetails.push({
productId: product._id,
productName: product.name,
quantity: item.quantity,
size: item.size,
pricePerUnit: product.basePrice,
total: itemTotal
});
}
// 2. Create Razorpay order with database-verified amount
const razorpayOrder = await razorpayInstance.orders.create({
amount: totalAmount * 100, // Convert to paise
currency: 'INR',
receipt: `order_${Date.now()}`
});
// 3. Save order to database with PENDING status
const newOrder = new Order({
razorpayOrderId: razorpayOrder.id,
items: orderDetails,
totalAmount: totalAmount,
status: 'PENDING',
userId: req.user.id
});
await newOrder.save();
// 4. Return order details to frontend
res.json({
orderId: razorpayOrder.id,
amount: totalAmount * 100,
currency: 'INR'
});
} catch (error) {
console.error('Error creating Razorpay order:', error);
res.status(500).json({ error: 'Failed to create order' });
}
});
// Route: POST /api/orders/verify-payment
router.post('/verify-payment', async (req, res) => {
try {
const { razorpay_order_id, razorpay_payment_id, razorpay_signature } = req.body;
// 1. Verify cryptographic signature
const crypto = require('crypto');
const hmac = crypto.createHmac('sha256', process.env.RAZORPAY_KEY_SECRET);
hmac.update(razorpay_order_id + '|' + razorpay_payment_id);
const generatedSignature = hmac.digest('hex');
if (generatedSignature !== razorpay_signature) {
return res.status(400).json({ error: 'Payment verification failed' });
}
// 2. Fetch Razorpay order from their API (double verification)
const razorpayOrder = await razorpayInstance.orders.fetch(razorpay_order_id);
// 3. Fetch our database order
const order = await Order.findOne({ razorpayOrderId: razorpay_order_id });
if (!order) {
return res.status(404).json({ error: 'Order not found' });
}
// 4. CRITICAL: Verify amounts match
if (razorpayOrder.amount !== order.totalAmount * 100) {
console.error('⚠️ AMOUNT MISMATCH DETECTED!');
console.error(`Database: ${order.totalAmount}, Razorpay: ${razorpayOrder.amount / 100}`);
return res.status(400).json({ error: 'Amount verification failed' });
}
// 5. Update order status and trigger fulfillment
order.status = 'PAID';
order.razorpayPaymentId = razorpay_payment_id;
order.paidAt = new Date();
await order.save();
// 6. Trigger Printrove API for printing and shipping
await triggerPrintroveOrder(order);
res.json({ success: true, message: 'Payment verified and order placed' });
} catch (error) {
console.error('Error verifying payment:', error);
res.status(500).json({ error: 'Payment verification failed' });
}
});
Key Security Principles Implemented
1. Zero-Trust Architecture
- Never trust client-side calculations
- Frontend sends only product IDs and quantities
- Backend fetches actual prices from database
- Price verification happens at multiple checkpoints
2. Cryptographic Verification
- Razorpay signature verification using HMAC SHA256
- Server-to-server API calls to fetch order details
- Amount matching between database and Razorpay
3. Fail-Safe SDK Initialization
- Graceful degradation when environment variables are missing
- Application stays online even if payment SDK fails
- Clear error messages for administrators
4. Audit Trail
- All payment attempts logged to database
- Timestamp tracking for every transaction
- Amount mismatches trigger alerts
Real-World Attack Scenarios Prevented
Attack 1: Frontend Price Manipulation
Before: User could modify JavaScript variables and pay ₹1 for ₹2,000 product
After: Backend ignores all frontend prices, queries database directly
Attack 2: Replay Attacks
Before: Reusing payment signatures could trigger multiple fulfillments
After: Order status checks prevent duplicate processing
Attack 3: Amount Manipulation
Before: User could create an order for ₹100, then modify the payment to ₹50
After: Double verification ensures paid amount matches created order amount
Performance Considerations
The Zero-Trust approach adds latency:
- Additional database queries for price verification
- Server-to-server API calls to Razorpay
- Cryptographic signature generation
Trade-off: ~200-300ms additional latency vs. potential ₹lakhs in fraud losses.
Optimization: Implemented database indexing on product IDs and caching for frequently accessed product prices.
Testing the Security
I tested the security by intentionally trying to exploit my own system:
Test 1: Price Manipulation via DevTools
// Tried modifying this in browser console
amount = 100; // ₹1 instead of ₹2000
Result: ✅ Payment modal opened with ₹1, but backend verification rejected it.
Test 2: Missing Environment Variables
# Deployed to Azure without RAZORPAY_KEY_SECRET
Result: ✅ Server booted successfully, payment endpoints returned clean 500 errors.
Test 3: Invalid Product IDs
// Sent non-existent product ID
cartItems = [{ productId: 'fake_id', quantity: 1 }]
Result: ✅ Backend returned 400 error before creating Razorpay order.
Lessons Learned
1. Security by Design, Not by Addition
Security cannot be patched later. The entire checkout flow must be architected with Zero-Trust principles from day one.
2. Production Environments Are Hostile
Local development hides configuration issues. Missing environment variables that cause crashes in production must be handled gracefully.
3. Third-Party SDKs Are Dependencies, Not Guarantees
Any external SDK can fail. Your application must survive their failure.
4. Always Verify Server-Side
Cryptographic signatures only prove a payment happened. They don't prove the amount is correct.
5. Monitor Everything
Log every payment attempt, every verification failure, every amount mismatch. Fraud patterns emerge in logs.
Conclusion
Building PinnacleWear wasn't just about learning React or Node.js syntax. It was a masterclass in anticipating how systems break in the real world.
Moving from a local development environment to a distributed, cloud-deployed production system requires a massive shift in mindset—from assuming things will work, to mathematically guaranteeing they won't fail when they don't.
Final Checklist for Payment Integration
✅ Never trust client-side price calculations
✅ Always fetch prices from database on backend
✅ Verify amounts match at multiple checkpoints
✅ Initialize SDKs defensively with try-catch
✅ Test with missing environment variables
✅ Log all payment events for audit trails
✅ Implement rate limiting on payment endpoints
✅ Test your own system like an attacker would
PinnacleWear is now live, secure, and processing real transactions. The extra 300ms latency is a small price to pay for preventing fraud and ensuring the business doesn't lose money to ₹1 hacks.