Payment Security Against Price Tampering and Double Charges

Always recompute amounts server-side: idempotency keys, live/test separation, and an abnormal-amount guard

price tampering preventiondouble charge preventionidempotency keyenvironment separationsecurity
6 min read

Introduction

When people hear "payment security," most picture encrypted connections and protecting card data. Those matter, of course — but the things that actually cause accidents tend to be far less glamorous.

Billing whatever amount the screen happened to show. Running the same charge twice. Charging a real card for a booking created in the test environment. This article walks through those traps and how we closed them off.

Never take the amount from the browser

Numbers on screen are trivially easy to change

The booking screen displays a total. If you send that value straight to the server and bill it, anyone can open their browser's developer tools, rewrite the amount to 1 yen, and stay for 1 yen.

Two ways to handle the amount
BEFORE
Bill the amount from the screen

Values on the browser side can be freely rewritten by the user. A booking goes through for 1 yen

AFTER
Recompute on the server and compare

Re-fetch the amount from the reservation management system and simply check it matches what was sent

So the amount arriving from the browser is used only as a reference value for comparison, and the amount we actually bill is the one the server re-fetches from the reservation management system. If the two don't match, we display "The rate has changed" and stop the process.

With this design, even if someone rewrites the amount, all that happens is the comparison fails and the booking halts. No damage done.

The server assembles option pricing too

The same applies to add-ons like renting barbecue equipment. All the browser sends is whether a box is ticked — no amounts whatsoever. Unit prices come from data on the server side.

Stick rigidly to "never accept an amount from the client" and there's simply no room left to tamper with.

Never run the same charge twice

Idempotency keys as protection

If a request times out in transit, the payment provider may have actually succeeded while it looks like a failure on our end. Retry at that point and you've double-charged.

Payment providers offer idempotency keys for exactly this situation. Attach a unique string to a request and any subsequent request with the same key isn't processed anew — it returns the result of the first one.

We used the booking number to set idempotency keys in all three places: creating the customer, creating the payment screen, and charging. That gives us a structural guarantee of one charge per booking.

Add constraints on the database side too

On top of the payment provider's protections, we put constraints on our own database that forbid duplicates. Unique constraints on the booking number, the payment screen identifier, and the notification event ID mean the same value can never be recorded twice.

Re-check state immediately before charging

The automatic charge job first fetches a list of eligible bookings, then bills them one at a time. In the meantime, a guest might cancel from their private page.

So immediately before executing a charge, we re-read that booking's latest state from the database. If it's already cancelled, we don't bill it. The idea is simple: don't keep trusting information from the moment you fetched the list.

Never mix up live and test

The danger of sharing one database

During development you verify behaviour against the test payment environment. If you're sharing the production database while you do that, there's a real risk that the live automatic charge job picks up bookings you created in test.

And that's exactly what happened during development. The production scheduled job scooped up a booking created in the test environment and treated it as a charge candidate.

Record which environment a booking came from

The fix was to add a field to the booking data itself recording whether it was created under live or test payments. The charge job only processes bookings matching the environment it's currently running in.

Layered defence against environment mismatches
Validate the key format

In production accept only live keys; everywhere else, only test keys

Stamp the environment on the booking

Record which environment it was, at the moment the booking is created

Compare at charge time

Bookings from a mismatched environment are quietly skipped as out of scope, not treated as errors

The key point is treating a mismatched booking as "out of scope" rather than "an error." Raise an error and an alert email goes to the operator — which means a notification every single time anyone runs a test.

If the keys aren't right, hide the feature entirely

When the payment keys are unset, or don't match the environment, we don't render the booking button at all. Far kinder to make booking unavailable from the outset than to let a guest get partway through and hit "An error has occurred."

Hold less, expose less

Send the payment provider only what it needs

The metadata we pass to the payment provider contains only the booking number and the dates — no name, phone number, or address whatsoever. There's no reason to leave personal data that the payment doesn't need sitting in an external service's logs.

By the same token, error messages and logs never output card details, tokens, or the URL of a guest's private page. Information that ends up in a log becomes a leak path in its own right.

Protect the scheduled job's entry point too

Scheduled jobs like the automatic charge can be triggered by anyone who learns the URL. So we put a shared-secret check in front of it — and configured it to refuse to run when the secret is unset.

A system that lets requests through unauthenticated because someone forgot to configure it is an accident waiting to happen. Far safer to fail closed: no configuration, no execution.

Wrapping up

Three things anchored our payment security.

  1. Never accept the amount from the client — what's sent is for comparison only; the server re-fetches what to bill
  2. Prevent duplicates twice over — the payment provider's idempotency keys plus unique constraints in the database
  3. Prevent environment mix-ups structurally — stamp the environment on the booking and never process a mismatch