Technical Paper

The Sandbox Payment
Vulnerability: Full Breakdown

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.

Published May 2026
Read time ~12 minutes
OWASP WSTG M5 Context
Stack Express.js + PostgreSQL

Bug Discovery — How We Found It

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.

⚠️ The Silent Failure Pattern
When sandbox keys are present in a live environment, or when the payment path doesn't verify the raw webhook signature, Stripe test card numbers (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.

express.js Vulnerable route ordering
// ❌ 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.

🔍 How to Detect This in Your Own Stack
Check your Stripe dashboard for any events where 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.

OWASP M5 — Why This Is a Widespread Vulnerability

The OWASP Web Security Testing Guide (WSTG) identifies this class of flaw under WSTG-BUSL-05: Test Payment Functionality. The guide is explicit:

📋 OWASP WSTG — WSTG-BUSL-05
"Test card details... should only be usable on development or sandbox versions of the gateways, but may be accepted on live sites if they have been misconfigured."

This vulnerability is not specific to Stripe. Any payment processor that provides sandbox/test credentials — Braintree, Adyen, PayPal, Square, Paddle — is subject to the same class of misconfiguration. The risk is structural: it lives in the integration layer, not the payment processor.

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.

ℹ️ Why Default Framework Configs Make This Worse
Express.js's 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.

The Complete Technical Fix

Four interlocking changes eliminate the vulnerability class entirely. None of them are optional — each one addresses a different attack surface. Deploy them together.

1
Raw Bytes Webhook Verification

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.

express.js Fix 1 — Raw body capture + Stripe webhook verification
// 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 });
});
Why This Works
Stripe computes the HMAC-SHA256 of the raw payload using your webhook signing secret. If the body is modified after receipt (even a single whitespace difference from JSON parsing/formatting), the signature won't match. By capturing the raw bytes before any transformation, we can verify with bit-for-bit accuracy. A forged payload sent directly to your endpoint will fail signature verification and be rejected before any business logic runs.
2
Idempotency Keys

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.

node.js Fix 2 — Idempotency with PostgreSQL row-level locking
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();
  }
}
sql Required table for idempotency
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';
3
Environment Segregation

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.

node.js Fix 3 — Startup assertion + runtime mode verification
// 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
});
4
Express Route Ordering — Webhooks Before Body Parsers

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()).

express.js Fix 4 — Correct route ordering for webhook security
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.
Deployment Checklist — Run This Before Going Live
  • Stripe webhook endpoint uses express.raw() and verifies on raw bytes
  • Webhook route is registered before express.json() in server.js
  • Startup assertion crashes the process if sandbox key is loaded in production
  • Runtime assertStripeMode() called before any payment operation
  • Idempotency key table exists and has a UNIQUE constraint on the key column
  • Webhook handler returns HTTP 200 within 30 seconds (Stripe retry window)
  • Webhook secret (STRIPE_WEBHOOK_SECRET) set in production environment — not in code
  • Test 4242 4242 4242 4242 card on the live endpoint — it must be rejected
  • Stripe dashboard shows correct webhook signing secret — test with Stripe CLI

Architecture Overview — What's in the Starter Pack

The 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.

Request Flow — Research Pipeline
User input Express.js Credit check Polsia Agent web search AI synthesis Email draft → DB
80-second pipeline budget. 50s web search with training-data fallback. Auto-retry on transient errors. Client disconnect handled.
Request Flow — Payment Pipeline
Stripe checkout link User completes payment Webhook → raw body verify Idempotency check Subscription activated
All 4 fixes active. Sandbox key assertion at startup. Webhook before body parser. Idempotency table with row-level locking.

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.