# 3D Secure Passed, Payment Failed: A Payment Gateway That Doesn't Know IPv6

> Customers on IPv6 passed 3D Secure, then the authorization failed with "CustomerIPAddress field must contain a valid ip address". The bank said "send IPv4", and its own docs disagree with each other. The fix wasn't at the bank. It was in DNS.

- Author: [Halit Yeşil](https://halityesil.com/en/about/)
- Published: 2026-10-19
- Language: English
- Canonical URL: https://halityesil.com/en/ipv6-payment-gateway-3d-secure/
- Turkish version: https://halityesil.com/sanal-pos-ipv6-customeripaddress-hatasi.md
- Category: Linux & Servers
- Tags: 3D Secure, Cloudflare, DNS, E-commerce, Garanti BBVA, Happy Eyeballs, IPv6, Payment Gateway, PHP
- License: https://creativecommons.org/licenses/by-nc/4.0/
- Cite as: Halit Yeşil. “3D Secure Passed, Payment Failed: A Payment Gateway That Doesn't Know IPv6”. halityesil.com, 2026-10-19. https://halityesil.com/en/ipv6-payment-gateway-3d-secure/

---

Picture walking into a bank with your new biometric passport. Security at the door scans it and waves you through. The clerk at the counter looks at the boxes on his form and says: "This number doesn't fit. Bring your old paper ID." You don't have an old paper ID. You replaced it years ago.

That is exactly the IPv6 payment gateway problem we hit at one of our e-commerce clients last weekend. The customer passes 3D Secure, enters the SMS code, and the order dies at the very last step. We'll go through where the error comes from, what the bank said, and what its documentation actually says. And I won't hide the most embarrassing part: the biggest lapse of reason that day wasn't at the bank. It was in our own email.

## The symptom: 3D Secure passes, authorization fails

The site sits behind Cloudflare, and payments go through Garanti BBVA's virtual POS, one of Turkey's large banks. Customers arriving over IPv4 are fine. For a customer arriving over IPv6 the flow goes like this: the 3D Secure page opens, the SMS check passes, the bank returns a successful authentication. Then our server sends the sale request (the authorization) and gets this back:

```text
respCode   : 92
respText   : Declined
Source     : GVPS
ReasonCode : 0003
ErrorMsg   : Giriş yaptığınız değerleri kontrol ediniz
SysErrMsg  : CustomerIPAddress field must contain a valid ip address
```

(The Turkish `ErrorMsg` reads "check the values you entered".) We suspected ourselves first: do we detect the IP differently in the two steps? We don't. The application reads the address fresh at each step, and the two values are identical. The same IPv6 address causes no trouble in the 3D step and is treated as "not a valid IP" at authorization.

## The bank's answer: "Send IPv4"

On Saturday evening, 17 October, we wrote to the bank's support team: the error log, the terminal details, and two questions. Is IPv6 supported, and if not, what is the recommended approach? The reply came Monday morning: IPv6 is not supported, send your transactions in IPv4 format.

In a second email we asked the question head on. How do we send, in IPv4 format, the real IP of a customer who has no IPv4 address? Can the field be left empty? Is there a development timeline? The second reply, the same day, was an expanded version of the first: IPv6 format is not supported, there is no work on it at the moment, IPv4 is required for approval.

So the problem itself came back as the solution. Back to the passport: you said "I don't have the old paper ID", and the clerk said "bring the old paper ID".

## What do the docs say? Two pages, two rules

I set the support reply aside and went to Garanti BBVA's [developer portal](https://dev.garantibbva.com.tr/). For the same customer IP there are two different rules on two different pages:

| Document | Field | Length | Format rule |
|---|---|---|---|
| [XML virtual POS, additional fields](https://dev.garantibbva.com.tr/en/virtualpos-additional-fields-product-information-shipping-non-3ds) | `Customer/IPAddress` | 20 bytes | "No format requirement" |
| [3D payment](https://dev.garantibbva.com.tr/en/switch-sales-3d-payment) | `customerIp` | max 20 characters | "It must be in ipv4 format." |

The XML document also says the field is mandatory for fraud checks. On format it says nothing restrictive; in fact it says outright that there is no format requirement. What we ran into is exactly this split: one stage accepts the address, the other rejects it.

## A 20-character box

An IPv4 address is at most 15 characters: `255.255.255.255`. An IPv6 address is eight groups of four hex digits with seven colons between them: 39 characters. A 20-character field was sized for IPv4 and never widened when IPv6 came along.

That alone doesn't explain it, though. Compressed IPv6 addresses (like `2001:db8::1`) fit into 20 characters easily. And the error doesn't say "too long", it says "not a valid IP". So on the authorization side an IPv4 validator runs before any length check matters.

One number for context: [according to the Internet Society](https://pulse.internetsociety.org/blog/18-years-later-ipv6-reaches-majority), citing Google's data, 50.10% of access to Google on 28 March 2026 came over IPv6. For the first time, more than half. The form at the counter is still printed for the old paper ID.

## The real lapse was ours

In our second email we told the bank that "customers on IPv6 don't always have a usable IPv4 address". It sounded reasonable. It was mostly wrong.

**The truth:** most people browsing over IPv6 today are dual-stack, meaning they have both an IPv4 and an IPv6 address. When a browser connects to a site, it gets both the A (IPv4) and AAAA (IPv6) records from DNS and races them using [RFC 8305](https://www.rfc-editor.org/rfc/rfc8305.html) (Happy Eyeballs). IPv6 is preferred by default. The IPv4 attempt starts after a recommended delay of 250 milliseconds. Whichever connects first carries the request; the other is dropped.

**Technical detail:** a server never sees both addresses in one request. Every connection has a single address family. With Cloudflare in front there are two separate connections: customer to Cloudflare, and Cloudflare to your server. The customer's real address reaches you in the `CF-Connecting-IP` header, and that header holds one address.

Cloudflare publishes AAAA records automatically for every proxied record. The browser picks IPv6. The customer very likely had an IPv4 address, but since the browser never needed it, we never saw it.

Even on genuinely IPv6-only mobile networks nothing changes. Those networks reach IPv4 sites through the carrier's NAT64/464XLAT gateway. If the site has no AAAA record, the customer shows up with the carrier's IPv4 address.

**The hard truth:** we described to the bank a "customer with no IPv4". We had invented that customer ourselves.

## The fix: don't put IPv6 on the door

If the payment gateway doesn't recognise IPv6, the browser in the payment flow must not get the chance to choose IPv6. We had three options:

| Option | What it does | The cost |
|---|---|---|
| Turn off Cloudflare's [IPv6 Compatibility](https://developers.cloudflare.com/network/ipv6-compatibility) | No AAAA for the zone, browsers come over IPv4 | Affects the whole zone; the docs show the setting as Enterprise-only |
| A separate payment subdomain (grey cloud, A record only) | Only the payment flow is forced onto IPv4 | No Cloudflare protection on that host, origin IP exposed |
| [Pseudo IPv4](https://developers.cloudflare.com/network/pseudo-ipv4/) | Derives a fake IPv4 in 240.0.0.0/4 from the IPv6 address | A made-up address goes into a field required for fraud checks |

Pseudo IPv4 is available on every plan and looks like the easy way out. But Cloudflare's own documentation says that range is experimental and not used for production traffic. Sending a hashed address into a field the bank has rules about doesn't solve the problem, it hides it.

We picked the first option, and the setting went live on 19 October at 10:49 UTC. Asking Cloudflare's own resolver returned two A records each for the apex and `www`, and no AAAA. Google's public DNS also returned empty AAAA answers for both names.

Two small notes. First, open HTTP/2 connections can keep being reused after the DNS TTL expires; a customer with the page still open may arrive over IPv6 for a while. Second, according to Cloudflare's docs, IPv6 traffic can still arrive through Tor (Onion Routing) with the setting off.

## The code's share: never send IPv6 to the bank

The DNS setting closes the door, but if something slips through, the application must not carry it to the bank. If it isn't IPv4, the request is never sent; it gets logged, and the customer is asked to reload instead of seeing a meaningless "Declined":

```php
<?php

declare(strict_types=1);

/**
 * Picks the customer IP to send to the bank.
 * Returns null if it isn't IPv4: the request never goes to the bank.
 */
function ipForBank(array $server): ?string
{
    // Behind Cloudflare, the real client address is in this header.
    // Only trust it on a setup where requests are guaranteed to come
    // from Cloudflare (lock the origin down to Cloudflare's IPs).
    $ip = $server['HTTP_CF_CONNECTING_IP'] ?? $server['REMOTE_ADDR'] ?? '';

    $ipv4 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_RES_RANGE);

    return $ipv4 === false ? null : $ipv4;
}
```

Running it with four sample addresses on PHP 8.3 printed:

```text
203.0.113.45                             12 chars -> 203.0.113.45
2001:db8:85a3:8d3:1319:8a2e:370:7348     36 chars -> DO NOT SEND
2001:db8::1                              11 chars -> DO NOT SEND
240.16.0.1                               10 chars -> DO NOT SEND
```

**Hidden gem:** the last line is rejected thanks to `FILTER_FLAG_NO_RES_RANGE`. Without that flag, `FILTER_FLAG_IPV4` on its own accepts 240.16.0.1 as a valid IPv4. If someone later switches on Pseudo IPv4 as "the easy way", the fake address slips through validation and goes to the bank without a sound. The flag rejects reserved ranges and closes that door too.

## Which layer is the problem in?

When a static CSS file returned 500 during [my PHP 8.5 upgrade](https://halityesil.com/en/php-8-5-upgrade-from-8-1/), the lesson was that the problem wasn't in PHP but a layer below. Same story here. The error message pointed at the payment module, but the decision was being made in DNS. It wasn't our code that decided which address arrived; it was the AAAA record we published.

Words don't mean the same thing everywhere, either. In [Urban Renewal #1](https://halityesil.com/en/urban-renewal-1-ai-refactoring-survey/) I wrote that "read-only" means four different things in four tools. Here, two documents from the same bank say both "no format requirement" and "must be IPv4" for the same customer IP.

The bank needs to add IPv6 support. Until that day we have to fill in the form at the counter. But we can do it by opening the door the right way, not by telling the customer to go fetch an old paper ID.

Stay with the technology, and know your AAAA records.
