Home/Journal/Teardown
TeardownCriticalauthenticationcardholder-datapci

Phone Number In, Cardholder Data Out

An unauthenticated endpoint returned full cardholder data keyed by phone number. Here is how we found it on a payments platform and how to close it.

Rahul Dharan··8 min read·a payments-orchestration platformMissing authentication on a regulated-data API (CWE-306)

The setup

The target was a payments-orchestration platform: the infrastructure other companies route card payments through. When a merchant or a bank wants to issue cards, process transactions, or move money, a platform like this one sits in the middle and does the heavy lifting. That makes it a concentrator of regulated data. It does not hold one company’s cardholder records; it holds many, on behalf of many issuers, all in one estate.

That concentration is exactly why we looked. A single company’s card data is a serious target. A platform that stores card data for a portfolio of banks is a far larger one, and the blast radius of a single mistake scales with everything routed through it.

Recon

Working black-box, from the open internet with no account, we mapped the platform’s service surface. A business this size does not run one application. It runs dozens of internal services behind shared load balancers, and the names of those services tend to leak. We pulled internal hostnames out of public DNS records and out of the JavaScript bundles the platform ships to browsers. Front-end code has to know where to send requests, so the names of internal services end up embedded in it.

What came back was a sprawl. Card-issuance services, ledger services, customer-profile services, all sitting on shared infrastructure, most of them behaving correctly when we probed them. Most returned 401 or 403. Most were doing their job.

The hypothesis

Our reasoning was about scale, not about any one weakness. On a large estate, authentication is applied service by service, and it is applied by different teams at different times under different deadlines. The main customer-facing APIs get the most scrutiny. The internal integration hosts - the ones meant to be called only by another service inside the network - get less, because the assumption is that nothing outside can reach them.

So the hypothesis was simple: somewhere in this sprawl, one integration host would have skipped authentication, because someone assumed it was internal-only and it turned out to be reachable. We were not looking for a clever exploit. We were looking for the one door in a large building that got left unlocked.

What we tried

We worked through the internal-sounding hosts, sending a minimal, read-only request to each and watching how it answered. Most rejected us. Then one card-issuance service answered a lookup request without asking for anything.

We sent it a single query keyed on a phone number. It returned a record. To be sure this was a real gap and not a cached or dummy response, we checked whether any credential changed the outcome: it did not. There was no Authorization header in play, no session, no token. The endpoint returned regulated data to an anonymous caller.

What we deliberately did not do matters just as much. Our rules of engagement were read-only, and this was regulated cardholder data. We proved the unauthenticated read on a small number of lookups, confirmed the shape of what came back, and stopped. We did not enumerate phone-number ranges, and we did not pull records at scale. The point was to establish that the door was open, not to walk out with the contents.

What we found

The endpoint returned a full cardholder profile keyed by phone number, with no authentication.

-> GET <card-issuance-host>/<lookup> ? phone=<number> & bank=<issuer-id>

<- {
     "primary_account_number": "<full PAN>",
     "date_of_birth": "<dob>",
     "annual_income": "<amount>",
     "address": { ... full postal address ... },
     ...
   }

Three things made this critical rather than merely bad.

We also found a follow-on ledger-query chain reachable from the same surface. It was plumbed to return transaction rows, which would have deepened the exposure. For our test cardholder it returned no rows, so we noted the reachable path and did not push it further. The unauthenticated read on the profile endpoint was finding enough.

Why it happens

No one decided to publish cardholder data. This is what a large service estate does over time.

An integration endpoint gets built so one internal service can look up a customer for another. Inside the network, called only by a trusted sibling, skipping authentication feels like a reasonable simplification - the caller is already inside, so why check again. Then the estate grows. A shared load balancer routes to the service. A DNS record points at it. Over months, the host that was “internal-only” becomes reachable from outside, and nobody re-audits the assumption that made skipping auth safe.

The flat data store is the second half. Under deadline, a single customer table that every issuer shares is simpler to build than per-issuer partitioning. The issuer parameter gets added to the API for correctness, but the enforcement lives in the calling code’s intentions, not in the data layer. So when the door opens, the scope that was supposed to contain the damage was never really there.

For developers

If you run APIs that return regulated data, four controls close this:

  1. Authenticate every endpoint that returns regulated data - internal ones included. “Only another service calls this” is an assumption that decays as your estate grows. Require a validated credential on every host that can return cardholder, health, or financial records, and reject requests that lack one with a real 401.
  2. Never key a sensitive lookup on an enumerable value. A phone number, an email, or a sequential ID is not a secret. If a lookup returns regulated data, gate it behind authentication and scope it to the authenticated caller, so knowing the key is not enough.
  3. Enforce tenant and issuer scoping in the data layer. A scope parameter the application is trusted to honor is not a boundary. Partition the data so that an issuer’s records are unreachable from another issuer’s context at the storage level, and verify that changing the scope parameter actually changes what a query can see.
  4. Inventory internal hosts that leak. Pull your own DNS and your own shipped JavaScript the way an attacker would. Any internal hostname that appears there is reachable, and reachable internal services need the same authentication as public ones.

And one detection step: alert on requests to your card-issuance and profile services that arrive without a valid credential. On a correctly configured estate that number is zero, so any nonzero count is either a probe or a gap.

The takeaway

On a large estate, security is only as strong as its least-audited host, and the least-audited host is almost always the one everyone assumed was internal. A single flat data store, a scope parameter that enforces nothing, and one integration endpoint that skipped authentication combined to turn a phone number into a full cardholder record for any issuer on the platform. The concentration that makes a payments platform valuable is the same concentration that makes one open door catastrophic.

This teardown is one instance of a pattern we see repeatedly. We wrote up the general case, and how to defend against it, in The Sibling Service That Skipped Authentication.

Frequently asked

What is an unauthenticated regulated-data API?
It is an API endpoint that returns regulated data - cardholder information, health records, financial details - without checking who is asking. Any client that knows the URL and a lookup key gets the data, so the only thing standing between an attacker and the records is knowledge of the request shape.
Why is keying a lookup on a phone number dangerous?
A phone number is enumerable and semi-public. If a sensitive lookup accepts a phone number as its key with no authentication, an attacker can iterate through number ranges and pull one record after another. Sensitive lookups must be gated by authentication and scoped to the caller, never by a guessable identifier alone.
How do you protect a cardholder-data API?
Authenticate every endpoint that returns regulated data, enforce tenant and issuer scoping in the data layer rather than trusting a request parameter, never key sensitive lookups on an enumerable value, and inventory every internal host that leaks into public DNS or client-side JavaScript.

This is one finding from a harness that runs continuously. See how Greywatch finds, proves, and fixes them.

How it works →