Warning: This is a simulation for testing purposes only. Do not use in production.
This POC simulates an end-to-end mutual TLS (mTLS) scenario mimicking a Payment Network's Card Authentication Service (PCAS) integration pattern:
- Client (your MacBook) → presents a client certificate signed by a mock "PayNet" CA
- HAProxy (Docker) → terminates TLS, verifies client certificate, forwards to WSO2
- WSO2 API Manager (Docker) → backend API gateway
┌──────────────┐ ┌──────────────────────────────────────────┐
│ │ mTLS │ Docker Desktop │
│ Client │◄────────►│ ┌───────────┐ ┌──────────────┐ │
│ (curl/ │ :443 │ │ HAProxy │──────►│ WSO2 APIM │ │
│ Postman) │ │ │ (mTLS │ :9443 │ (API Gateway)│ │
│ │ │ │ termination) │ │ │
└──────────────┘ │ └───────────┘ └──────────────┘ │
└──────────────────────────────────────────┘
flowchart TB
CA["test_ca.crt<br/>(Mock PayNet Root CA)"]
SERVER[server.crt<br/>CN=apigw.example.com]
CLIENT[client_test.crt<br/>CN=TestClient01]
SERVER_ID["server_identity.pem<br/>(server.crt + server.key)"]
TRUST["client_trust.crt<br/>(contains test_ca.crt)"]
HAP[HAProxy]
CURL["Client (curl/Postman)"]
%% Signing relationships
CA -->|Signs| SERVER
CA -->|Signs| CLIENT
%% Bundles / copies
SERVER --> SERVER_ID
CA --> TRUST
%% Runtime usage
SERVER_ID --> HAP
TRUST --> HAP
CLIENT --> CURL
sequenceDiagram
autonumber
participant C as Client
participant H as HAProxy
participant CA as test_ca.crt
Note over H: Loads server_identity.pem<br/>(server.crt + server.key)
Note over H: Loads client_trust.crt<br/>(contains test_ca.crt)
%% -------------------------
%% 1️⃣ Server Authentication
%% -------------------------
C->>H: ClientHello
H-->>C: ServerHello
H-->>C: Certificate (server.crt)
Note over C: Validate server.crt<br/>against trusted CA store
C->>CA: Verify server.crt signature
CA-->>C: Valid (signed by test_ca.crt)
C->>C: Generate PreMasterSecret
C->>H: Encrypted PreMasterSecret<br/>(encrypted using server public key)
H->>H: Decrypt using server.key
%% -------------------------
%% 2️⃣ Client Authentication
%% -------------------------
H-->>C: CertificateRequest
C-->>H: client_test.crt
Note over H: Validate client_test.crt<br/>using client_trust.crt
H->>CA: Verify client_test.crt signature
CA-->>H: Valid
C->>H: CertificateVerify<br/>(signed using client_test.key)
Note over H: Verify signature using<br/>public key in client_test.crt
Note over H: ssl_c_verify = 0 if success
- Docker Desktop installed and running on macOS
- OpenSSL available (pre-installed on macOS)
- Terminal access
POC/
├── certs/
│ ├── test_ca.key # CA private key (mock PayNet CA)
│ ├── test_ca.crt # CA certificate (mock PayNet CA)
│ ├── server.key # Server private key (HAProxy)
│ ├── server.crt # Server certificate (HAProxy) — signed by CA
│ ├── server_identity.pem # Combined server key + cert for HAProxy
│ ├── client_trust.crt # CA cert used by HAProxy to verify clients
│ ├── client_test.key # Client private key
│ ├── client_test.crt # Client certificate — signed by CA
│ ├── client_test.csr # Client CSR (intermediate artifact)
│ ├── server.csr # Server CSR (intermediate artifact)
│ └── server_ext.cnf # Server cert config with SAN
├── haproxy/
│ └── haproxy.cfg # HAProxy configuration
├── docker-compose.yml # Docker Compose for HAProxy + WSO2
├── instructions.txt # Original instructions
└── STEP-BY-STEP-GUIDE.md # This file
All certificates have already been generated and are in the certs/ directory. If you want to generate them again, you can follow the instructions in the CERTIFICATE-GENERATION-GUIDE.md file. Click here to view the certificate generation guide
Here's what each one does:
| File | Role | Description |
|---|---|---|
test_ca.key |
CA Private Key | Private key for the mock "PayNet" Certificate Authority |
test_ca.crt |
CA Certificate | Root cert (mock PayNet CA) that signs both server and client certs |
server.key |
Server Private Key | HAProxy's private key for TLS |
server.crt |
Server Certificate | HAProxy presents this to clients (CN=apigw.example.com) |
server_identity.pem |
Server Bundle | server.key + server.crt combined for HAProxy |
client_trust.crt |
Client Trust Store | Copy of test_ca.crt (mock PayNet CA) — HAProxy uses this to verify client certs |
client_test.key |
Client Private Key | The client's private key (used by curl) |
client_test.crt |
Client Certificate | The client's cert signed by the CA (used by curl) |
test_ca.crt (Root CA — "Test-PayNet-CA")
├── server.crt (CN=apigw.example.com, SAN: localhost, 127.0.0.1)
└── client_test.crt (CN=TestClient01)
The HAProxy config is at haproxy/haproxy.cfg. Key settings:
frontend https-in
bind *:443 ssl crt /etc/haproxy/certs/server_identity.pem ca-file /etc/haproxy/certs/client_trust.crt verify optional
acl is_pcas_path path_beg /pcas/v1/
acl client_verified ssl_c_verify 0
# Deny if client cert not verified on /pcas/v1/ paths
http-request deny status 403 if is_pcas_path !client_verified
default_backend wso2_apim
backend wso2_apim
server wso2 wso2-apim:8243 ssl verify noneKey points:
verify optional— HAProxy requests a client cert but doesn't force it for all pathsssl_c_verify 0— means client cert verification succeeded (0 = success)/pcas/v1/paths require a valid client cert (mTLS enforced)- Other paths work without a client cert (standard TLS)
- Client cert details are forwarded to WSO2 via
X-SSL-*headers
Add the hostname apigw.example.com to your local hosts file so it resolves to 127.0.0.1:
sudo sh -c 'echo "127.0.0.1 apigw.example.com" >> /etc/hosts'Verify:
ping -c 1 apigw.example.comExpected output: PING apigw.example.com (127.0.0.1)
From the POC/ directory:
docker compose up -dCheck that both containers are running:
docker-compose psExpected output:
NAME STATUS
haproxy-mtls Up
wso2-apim Up
Note: WSO2 APIM takes ~2-3 minutes to fully start. Wait for it before testing.
Check WSO2 readiness:
docker logs wso2-apim 2>&1 | grep -i "started"curl -k https://apigw.example.com/pcas/v1/productsExpected: HTTP 403 Forbidden — HAProxy denies because no client cert was presented on a /pcas/v1/ path.
curl -k \
--cert certs/client_test.crt \
--key certs/client_test.key \
https://apigw.example.com/pcas/v1/productsExpected: The request passes through HAProxy to WSO2 APIM. You may get a 404 from WSO2 (no API deployed yet), but not a 403 — that means mTLS succeeded!
curl -k https://apigw.example.com/Expected: This should pass through to WSO2 without requiring a client cert (HAProxy verify optional allows it).
curl -kv \
--cert certs/client_test.crt \
--key certs/client_test.key \
https://apigw.example.com/pcas/v1/testLook for these lines in the output:
* TLSv1.2 (OUT), TLS handshake, Certificate (11):
* TLSv1.2 (IN), TLS handshake, Request CERT (13):
This confirms the server requested a client cert and the client provided one.
HAProxy forwards these headers to WSO2 APIM:
| Header | Value |
|---|---|
X-SSL-Client-Verify |
0 (success) |
X-SSL-Client-DN |
/CN=TestClient01/O=Test Client/C=US |
X-SSL-Client-CN |
TestClient01 |
X-SSL-Issuer |
/CN=Test-PayNet-CA/O=Test Payment Network Certificate Authority/C=US |
You can verify these by deploying a simple echo API in WSO2 or checking HAProxy logs:
docker logs haproxy-mtlsopenssl verify -CAfile certs/test_ca.crt certs/client_test.crtExpected: certs/client_test.crt: OK
openssl verify -CAfile certs/test_ca.crt certs/server.crtExpected: certs/server.crt: OK
openssl x509 -in certs/client_test.crt -noout -text
openssl x509 -in certs/server.crt -noout -text
openssl x509 -in certs/test_ca.crt -noout -textdocker-compose downdocker logs haproxy-mtlsCommon causes:
- Certificate file permissions — ensure files are readable
- Invalid PEM format — check
server_identity.pemhas both key and cert
- Ensure Docker Desktop is running
- Check no other service is using port 443:
lsof -i :443
- Verify cert is signed by the correct CA:
openssl verify -CAfile certs/test_ca.crt certs/client_test.crt - Ensure
client_trust.crtin HAProxy matchestest_ca.crt
- Wait 2-3 minutes after container start
- Check logs:
docker logs wso2-apim
Once the POC works, transition to production by:
- Replace
server_identity.pemwith your real production server cert + key - Replace
client_trust.crtwith the real Payment Network Root/Intermediate CA certificates - The real Payment Network client will present their cert signed by the real CA
- Change
verify optionaltoverify requiredif all paths need mTLS
| Role | File | Description |
|---|---|---|
| Server Identity | server_identity.pem |
HAProxy presents this to clients during TLS handshake |
| Client Verification | client_trust.crt |
HAProxy uses this CA to verify incoming client certs |
| The Client | client_test.crt + client_test.key |
Used by curl/Postman to authenticate to the server |
| Certificate Authority | test_ca.crt + test_ca.key |
Mock "PayNet" CA that signs both server and client certs |