How we discovered and fixed a structural payment flaw in our own production stack — and why this class of bug is far more widespread than most engineering teams realize. Includes complete code examples, deployment checklist, and OWASP context.
We were building Pinpitch's subscription system using Stripe checkout links. We had a Growth plan at $29/month and a Pro plan at $79/month, running in sandbox mode using Stripe test keys. One day, a test transaction from Stripe's sandbox dashboard appeared to succeed — real-looking confirmation email, real-looking Stripe dashboard entry, no error anywhere. Except nothing was actually charged to any bank.
The transaction looked valid. The webhook fired. The user appeared active. But the charges weren't real — and we had no way to know the difference by looking at the Stripe dashboard alone.
4242 4242 4242 4242, etc.) are
silently accepted — appearing as successful payments in your dashboard with no actual revenue
moving. This is especially dangerous because the failure is invisible: the UI looks fine, the
webhook fires, the user record updates. Only the bank statement reveals nothing happened.
Here's what we discovered: when Express receives a Stripe webhook, the default middleware
express.json() parses the raw request body into a JavaScript object. Stripe's signature
verification expects the raw bytes of that body — not the parsed JSON. If you verify after
parsing, the signature header no longer matches because the body has been transformed. An attacker
could send a forged payload that looks like a valid subscription event. The signature check fails
silently (many implementations don't handle this gracefully), and the forged event gets processed.
// ❌ VULNERABLE — body parsed before signature verification const app = express(); // ❌ This runs BEFORE webhook verification — body already parsed app.use(express.json()); // transforms raw bytes → JSON object app.use(express.urlencoded()); // By the time this runs, req.body is already parsed — // Stripe signature verification will fail or be bypassed app.post('/webhook/stripe', (req, res) => { // req.body has already been transformed — signature mismatch! const sig = req.get('Stripe-Signature'); const event = verifyStripeSignature(rawBody, sig); // rawBody is GONE });
The second issue was environment segregation. We had two Stripe keys — test and live — but they were both loaded in the same environment with different variable names. In a misconfigured deployment, the live key could end up processing test card numbers if the webhook endpoint didn't explicitly verify which mode it was in. Sandbox keys are meant for development environments with no real money; but when they're accidentally deployed to production, they create a silent revenue leak.
payment_intent.status shows
"succeeded" but charges.data[].captured is false — or where
your database shows an active subscription but no actual payout has appeared in your Stripe
balance. Another tell: transactions that succeed in the Stripe dashboard but never appear in
your bank account settlement reports. Run this against your webhook endpoint with
4242 4242 4242 4242 and verify whether it gets processed as a valid event.
The bug cost us roughly 3 weeks of engineering time to find and root out across the full payment path. The Starter Pack ships the complete fix — you get that work done on day one.
The OWASP Web Security Testing Guide (WSTG) identifies this class of flaw under WSTG-BUSL-05: Test Payment Functionality. The guide is explicit:
This is a class M5 vulnerability — meaning it's a flaw in business logic, not in cryptography or server configuration. These are often the hardest to catch in security reviews because they require understanding the full business flow, not just the code.
| Vulnerability Class | OWASP Category | Risk Level | Affected Processors |
|---|---|---|---|
| Sandbox key in production | WSTG-BUSL-05 | High | All — Stripe, Braintree, PayPal, Adyen, Square |
| Webhook signature not verified on raw body | WSTG-INPV-08 | High | All webhook-based processors |
| No idempotency key on payment operations | WSTG-ERRR-02 | Medium | All — duplicates cause revenue loss or over-charge |
| Body parsed before signature verification | WSTG-INPV-08 | High | Express.js, Django, Rails, Laravel (default configs) |
| No environment mode verification on payment path | WSTG-BUSL-05 | Medium | Any multi-environment deployment |
The structural fix isn't just "use the right API key." It's an architecture change: you need to verify webhook signatures on the raw request body before any parsing happens, implement idempotency keys so duplicate webhooks don't double-process, and segregate environments so sandbox traffic can never reach the live payment path regardless of what keys are configured.
express.json() middleware parses the body before your route handler
runs. Django's CSRF and JSONParser middleware do the same. Rails
params parsing does the same. Every popular web framework has a default middleware stack that
transforms the raw request body — which breaks webhook signature verification unless you
explicitly handle it. This is why the fix requires route ordering changes, not just a code patch.
Four interlocking changes eliminate the vulnerability class entirely. None of them are optional — each one addresses a different attack surface. Deploy them together.
Stripe's webhook signature is computed over the raw request body bytes — not the parsed JSON.
Express's express.json() middleware consumes those bytes before your route handler
can access them. The fix: capture the raw body before any middleware parses it, then verify
the signature against that raw buffer.
Use a custom body parser that exposes req.rawBody alongside req.body.
Stripe's Node SDK's constructEventAsync (or the sync constructEvent)
takes the raw body string and the signature header. Verify first, then proceed.
// Register raw body capture BEFORE express.json() app.use('/' , express.raw({ type: 'application/json', limit: '10mb' }), (req, res, next) => { req.rawBody = req.body; // store raw bytes req.body = JSON.parse(req.rawBody); // also expose parsed JSON next(); }); // Global JSON parser for all other routes app.use(express.json()); // Stripe webhook — raw body available for signature verification app.post('/webhook/stripe', async (req, res) => { const sig = req.get('Stripe-Signature'); const secret = process.env.STRIPE_WEBHOOK_SECRET; let event; try { event = stripe.webhooks.constructEvent( req.rawBody, // ← raw bytes, NOT parsed body sig, secret ); } catch (err) { console.error(`Webhook signature verification failed: ${err.message}`); return res.status(400).send(`Webhook Error: ${err.message}`); } // Only proceed if verification passes — forged events rejected here await handleStripeEvent(event); res.json({ received: true }); });
Stripe webhooks can be delivered more than once — Stripe retries delivery if it doesn't receive a 2xx response within a window. Without idempotency protection, a duplicate webhook could credit a user's subscription twice, apply a refund twice, or update a record with stale data. Idempotency keys prevent this by making payment operations deterministic: the same event ID always produces the same result, regardless of how many times it's received.
async function handleStripeEventWithIdempotency(event) { const idempotencyKey = `stripe_${event.id}`; const client = await pool.connect(); try { await client.query('BEGIN'); // Try to claim the idempotency slot — returns 0 rows if already processed const claimResult = await client.query( `INSERT INTO idempotency_keys (key, event_type, processed_at) VALUES ($1, $2, NOW()) ON CONFLICT (key) DO NOTHING RETURNING id`, [idempotencyKey, event.type] ); if (claimResult.rowCount === 0) { console.log(`Duplicate webhook ignored: ${event.id}`); await client.query('COMMIT'); return; } // Safe to process — we own the idempotency slot await processStripeEvent(event, client); await client.query('COMMIT'); } catch (err) { await client.query('ROLLBACK'); throw err; } finally { client.release(); } }
CREATE TABLE IF NOT EXISTS idempotency_keys (
id BIGSERIAL PRIMARY KEY,
key VARCHAR(255) UNIQUE NOT NULL,
event_type VARCHAR(100) NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata JSONB
);
-- Index for fast duplicate lookups (the hot path)
CREATE UNIQUE INDEX IF NOT EXISTS
idx_idempotency_keys_key ON idempotency_keys (key);
-- Purge old entries to keep table lean (cron job)
DELETE FROM idempotency_keys
WHERE processed_at < NOW() - INTERVAL '30 days';
Sandbox and live keys must never share a runtime environment. Use a single
STRIPE_SECRET_KEY env var — but ensure its mode is verified at startup
and at request time. On application boot, assert that the key prefix matches the
intended mode. At runtime, validate on every payment operation that the key in use
hasn't been accidentally swapped.
The single most effective safeguard: a startup assertion that crashes the process if the wrong key type is loaded in production. An application that refuses to start with a sandbox key in a production environment is far better than one that silently accepts test cards.
// At application startup — crashes if wrong key type in wrong environment const STRIPE_KEY = process.env.STRIPE_SECRET_KEY; const NODE_ENV = process.env.NODE_ENV; if (NODE_ENV === 'production' || NODE_ENV === 'prod') { if (!STRIPE_KEY || !STRIPE_KEY.startsWith('sk_live_')) { throw new Error( 'FATAL: STRIPE_SECRET_KEY must be a live key (sk_live_*) in production. ' + 'Current key starts with: ' + (STRIPE_KEY?.split('_'0) ?? ['MISSING']).join() ); } } // Runtime verification — assert at the payment operation level function assertStripeMode() { const key = process.env.STRIPE_SECRET_KEY; if (key.startsWith('sk_test_') && process.env.NODE_ENV === 'production') { throw new Error('REFUSING to process payment: test key in production mode'); } } // Call before any payment operation app.post('/api/create-checkout-session', async (req, res) => { assertStripeMode(); // crashes if wrong key type // ... proceed with payment });
This is the most overlooked fix. Express routes are evaluated top-to-bottom. If your
webhook route is registered after express.json(), the body has already been
consumed before the webhook handler runs. The solution: register the Stripe webhook route
before any body-parsing middleware, using express.raw() scoped to
/webhook/stripe only. All other routes use the standard express.json()
pipeline.
This requires registering the webhook route at module level before the global middleware,
or using a dedicated router that mounts before app.use(express.json()).
const app = express(); // ── 1. Webhook route FIRST — needs raw body, before any body parser ── // Capture raw body ONLY for /webhook/stripe — all other routes unaffected app.post( '/webhook/stripe', express.raw({ type: 'application/json' }), // raw body only for this route stripeWebhookHandler ); // ── 2. Body parsers SECOND — all other routes get parsed JSON/URL encoded ── app.use(express.json()); app.use(express.urlencoded({ extended: true })); // ── 3. All other routes AFTER body parsers ── app.post('/api/create-checkout-session', requireAuth, createCheckoutSession); app.get('/api/my-subscriptions', requireAuth, getMySubscriptions); // ✅ Webhook gets raw body. All other routes get parsed body. // ✅ Stripe signature verification works because raw bytes are preserved.
express.raw() and verifies on raw bytesexpress.json() in server.jsassertStripeMode() called before any payment operationkey columnSTRIPE_WEBHOOK_SECRET) set in production environment — not in code4242 4242 4242 4242 card on the live endpoint — it must be rejectedThe Starter Pack is the complete Pinpitch codebase, not a simplified teaching example. Every file was built, deployed, and battle-tested in a live production environment before being packaged for license. Here's what you receive.
The codebase covers the full stack — auth, research, payments, analytics, CSV uploads, lookalike generation, email delivery, and admin dashboard. Every component has production-grade error handling, logging, and rollback logic. The payment fix isn't a patch: it's the entire payment architecture rebuilt with these safeguards in place.
| Component | File(s) | What You Get |
|---|---|---|
| Server & routing | server.js, routes/ |
224-line entry file with middleware wiring, route mounting, health check |
| Research pipeline | lib/web-search.js, lib/polsia-ai.js |
Two-tier search, chatWithRetry(), 80s budget, client disconnect handling |
| Webhook security | routes/payments.js, db/users.js |
Raw body verification, idempotency keys, environment assertion, Stripe lifecycle |
| Database | db/, migrations/ |
All queries in named functions, migration system, Neon-compatible schema |
| Analytics | routes/analytics.js, db/analytics.js |
Full funnel: page_view → signup → research → paywall. UTM chain preserved. |
| Admin dashboard | routes/admin.js |
Owner-only analytics view, daily funnel event counts, date range filters |
| Auth system | routes/auth.js, lib/auth-middleware.js |
Cookie sessions, bcrypt passwords, plan-gated features, usage tracking |
| Views | views/ |
EJS templates for landing, app, auth, payment-success, comparison pages |
The Starter Pack ships with the exact same code that runs at pinpitch.polsia.app — every bug fix, every deployment config, every architectural decision. You're not getting a template. You're getting a deployed product you can rebrand and ship under your own domain.