MR
system designsecuritypayment gatewayfintechsoftware engineeringsoftware engineermicroservices

How Payment Gateways Keep Your Money (and Data) Safe

Payment gateways are the invisible middlemen behind online purchases. Building them is complex, and this article dives into one crucial aspect of their design: security.

Different Payment Gateway Providers
How Payment Gateways Keep Your Money (and Data) Safe

The Core Architecture

Before we get into the security deep-dives, let's establish who the players are and how they interact at a high level.

There are four core entities:

Merchant — A business that wants to charge customers for something. They integrate with the payment gateway via an API.

Client — The end user (customer) sitting in a browser or app, entering their card details.

PaymentIntent — When a merchant wants to charge a customer, they create a PaymentIntent. Think of it as a formal "I want to collect X amount from this person." It carries the state of the payment — pending, succeeded, or failed.

Transaction — Once payment is actually attempted and processed through the bank, a Transaction is created. It's the record of what actually happened.

This is how the architecture for our systems looks like initially:

HLD for Initial Architecture
HLD for Initial Architecture
  1. API Gateway: This serves as the entry point for all merchant requests. It handles authentication, rate limiting, and routes requests to the appropriate microservices.
  2. Payment Service: This microservice is responsible for creating and managing PaymentIntents.
  3. Transaction Service: A dedicated microservice responsible for receiving card details from the merchant server, managing transaction records throughout the payment lifecycle, and interfacing directly with external payment networks like Visa, Mastercard, and banking systems.
  4. Database: A central database that stores all system data including PaymentIntents records and merchant information

The Flow:

Setting up the payment (Merchant → PaymentIntent Service):

  • Merchant calls POST /payment-intents with the amount, currency, and description
  • API Gateway authenticates the merchant, then routes the request to the PaymentIntent Service
  • A PaymentIntent record is created with status created and a unique ID is returned to the merchant

Actually taking the payment (Client → Transaction Service):

  • Customer enters card details on the merchant's site and submits
  • Merchant sends that data to our Transaction Service, along with the PaymentIntent ID
  • Transaction Service creates a Transaction record with status pending
  • It connects directly to the payment network (Visa, Mastercard, etc.), sends the authorization request, and waits
  • As the network responds — approved, declined, settled — the Transaction record updates accordingly
  • The PaymentIntent status stays in sync throughout, so the merchant always knows where things stand

Simple enough on paper. But there are two hard security problems hiding in this flow.

Problem 1: Is the Merchant Who They Say They Are?

Merchants get the ability to charge people money through our system. That's a big deal. So before we let anyone create a PaymentIntent, we need to verify they're a legitimate, registered merchant — not someone impersonating one.

The most common approach here is API keys. When a merchant onboards, we generate a unique key (something like pk_live_51NzQRt... devs who have used Stripe might know where this is headed.) and associate it with their account in our database. Every request they make includes this key in the header, and we validate it on our end before doing anything.

It's simple, and it works — but it has a real weakness. API keys are static. They don't expire, they don't rotate automatically, and they're often hardcoded into apps or config files. If a key leaks (through a public GitHub repo, for example), an attacker can use it indefinitely.

For a financial system, that's not good enough. The better approach is to layer on top of this with short-lived signed tokens (like JWTs) that expire quickly, plus key rotation policies that limit the damage window if something does get compromised.

JSON
// Example request with signature
{
  "method": "POST",
  "path": "/payment-intents/{paymentIntentId}/transactions",
  "body": { ... },
  "headers": {
    "Authorization": "Bearer pk_live_51NzQRtGswQnXYZ8o", // API Key
    "X-Request-Timestamp": "2023-10-15T14:22:31Z", // Timestamp
    "X-Request-Nonce": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", // Nonce
    "X-Signature": "sha256=7f83b1657ff1fc53b92dc18148a1d65dfc2d4b1fa3d677284addd200126d9069" // Hash of body
  }
}


Problem 2: Protecting the Customer's Card Data

This one is trickier and arguably more important. A customer's credit card number is extremely sensitive. If it gets stolen, it can be used for fraud, identity theft, and a total loss of trust. There are also strict regulations (PCI DSS) that dictate exactly how card data must be handled.

The core rule is simple: card data should never touch the merchant's server.

Here's why each approach matters:

The naive approach: merchant handles card data

The merchant's site collects the card number, sends it to their own server, and then their server forwards it to us. This seems fine on the surface, but every merchant server that touches card data becomes a target. One poorly secured merchant = one potential breach. PCI DSS compliance becomes the merchant's problem too, which is expensive and complicated.

Better: use an iframe

We solve the "don't touch the merchant's server" problem by providing a JavaScript SDK that renders a secure iframe directly on the merchant's page. The iframe loads from our domain. When the customer types in their card details, that data goes straight to us — the merchant's code can't even read it, thanks to the browser's same-origin policy.

Customer provides card details through an iFrame presented in Merchant's website.
Customer provides card details through an iFrame presented in Merchant's website.

This is a big improvement.

But the iframe's security depends entirely on browser-level protections, and the card data is only encrypted during transit via HTTPS. If the iframe itself were compromised, the data would be exposed.

Best: encrypt on the device before it leaves

We go one step further. Our system generates a public/private keypair. The public key is embedded in our JavaScript SDK. When a customer enters their card details in the iframe, the SDK encrypts the data using that public key right there in the browser, before it ever leaves the device.

What travels over the wire to our servers is already encrypted. We decrypt it server-side using the private key, which never leaves our secure infrastructure (stored in Hardware Security Modules).

This means even if HTTPS were somehow broken, the attacker would only get ciphertext they can't read. Even if the iframe were compromised, there's nothing useful to steal. It's defense in depth — multiple layers, each independently protecting the data.

iFrame with encryption
iFrame with encryption

Wrapping Up

Security in a payment system isn't one decision — it's a series of them, each one plugging a gap the previous approach left open. The pattern here is consistent: start with the simplest thing, identify where it breaks down, and add a layer that addresses exactly that weakness.

The merchant authentication story goes from static API keys → short-lived tokens. The card data story goes from merchant-handled → iframe isolation → client-side encryption. Each step is motivated by a specific threat, not just theoretical paranoia.

That's it for this one. You can follow me on X and LinkedIn if you enjoyed reading this and let the algo show you more of this.

Check out the blogs section for more writings.