Back to Blog
2026-02-22
8 min read

How I Accidentally Left My E-Commerce Checkout Vulnerable to ₹1 Hacks (And Then Crashed Production)

#E-Commerce#Security#Razorpay#Payment Gateway#Node.js#React#Azure#Backend#Zero-Trust Architecture

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:

  1. The React app calculates the total cart value.
  2. The React app passes that amount to the window.Razorpay initializer.
  3. 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:

  1. API Interception: When "Pay" is clicked, React calls a new backend endpoint: POST /api/orders/create-razorpay-order.

  2. 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 basePrice of every SKU.

  3. Cryptographic Locking: The backend talks to Razorpay server-to-server and generates an order_id locked to the database's price.

  4. Double Verification: The frontend uses that order_id to 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.