Skip to content
Yassir Halaoui

Secure integration · 8 min read

Ingesting HSM-encrypted files over SOAP with mTLS and WS-Security

By Yassir HalaouiPublished

In short

Ingesting HSM-encrypted files over SOAP needs three separate layers, and confusing them is the usual cause of failure: mutual TLS authenticates the two machines, WS-Security signs and authenticates the message itself, and the payload stays encrypted end to end until it reaches the component holding the key. In Spring Boot 3.5 that means a client-authenticated SSLContext on the transport, WSS4J interceptors on the message, and an asynchronous, idempotent handler behind the delivery webhook.

A card processor hands you encrypted files. Not over SFTP, not over an S3 bucket with a pre-signed URL — over SOAP, secured with mutual TLS on the outside and WS-Security on the inside, with the payload itself encrypted by a hardware security module that neither you nor the transport ever gets to decrypt.

This is what a lot of payment integration still looks like in 2026, and the public material for it is thin. What follows is the shape of a working integration on Spring Boot 3.5 and Java 21, and the parts that cost the most time.

The three layers people conflate

Almost every wasted day on this kind of integration comes from treating the security as one thing. It is three, they are independent, and they fail differently.

LayerProtectsFails as
Mutual TLSThe connection between two machinesHandshake failure, no message sent
WS-SecurityThe SOAP message itselfMessage rejected, SOAP fault returned
Payload encryptionThe file contents, end to endMessage accepted, contents unusable

Mutual TLS proves which machine is calling. WS-Security proves who sent this message and that it has not been altered, and survives being stored, logged, or forwarded through a proxy that terminates TLS. Payload encryption means the file stays sealed until it reaches the one component holding the key.

A partner asking for "mTLS and WS-Security" is not being redundant. They are asking for transport authentication and message-level non-repudiation. If you sign the message but skip the client certificate, the connection is refused. If you do mTLS but skip the signature, you get a SOAP fault with a message about a missing security header and no other clue.

Layer 1: mutual TLS on the transport

Spring WS 4 (which is what Spring Boot 3.x pulls in) uses Apache HttpClient 5, so the message sender you want is HttpComponents5MessageSender. The important detail is the separation of the two stores:

  • The key store holds your private key and certificate. This is what you present to the other side.
  • The trust store holds the CA chain that issued their certificate. This is what you validate against.

Keeping them in one file is the single most common cause of a rotation going wrong six months later, because the two halves are then on the same change schedule when they should not be.

MtlsMessageSenderConfig.java
@Bean
HttpComponents5MessageSender processorMessageSender(SoapProperties props)
    throws Exception {
 
  var keyStore = KeyStore.getInstance("PKCS12");
  try (var in = Files.newInputStream(props.keyStorePath())) {
    keyStore.load(in, props.keyStorePassword());
  }
 
  var trustStore = KeyStore.getInstance("PKCS12");
  try (var in = Files.newInputStream(props.trustStorePath())) {
    trustStore.load(in, props.trustStorePassword());
  }
 
  var sslContext = SSLContextBuilder.create()
      .loadKeyMaterial(keyStore, props.keyStorePassword())
      .loadTrustMaterial(trustStore, null)   // null: no custom trust strategy
      .build();
 
  var tlsStrategy = ClientTlsStrategyBuilder.create()
      .setSslContext(sslContext)
      .build();
 
  var connectionManager = PoolingHttpClientConnectionManagerBuilder.create()
      .setTlsSocketStrategy(tlsStrategy)
      .build();
 
  var client = HttpClients.custom()
      .setConnectionManager(connectionManager)
      .build();
 
  return new HttpComponents5MessageSender(client);
}

Two things worth doing here that are easy to skip:

Never pass a trust-all strategy, not even in a staging profile. The moment it exists in the codebase it will be copied into production by someone in a hurry, and mTLS with a trust-all client is just TLS with extra steps.

Log the certificate you actually presented at startup, with its subject and notAfter. When the handshake fails eighteen months from now, the first question is always "which certificate is it using", and the answer should already be in the logs rather than requiring a debugger against production.

Layer 2: WS-Security on the message

Wss4jSecurityInterceptor handles both directions. Outbound, you sign; inbound, you verify their signature and reject anything unsigned or replayed.

WsSecurityConfig.java
@Bean
Wss4jSecurityInterceptor securityInterceptor(SoapProperties props) throws Exception {
  var interceptor = new Wss4jSecurityInterceptor();
 
  // Outbound: timestamp first, then signature over it.
  interceptor.setSecurementActions("Timestamp Signature");
  interceptor.setSecurementUsername(props.signingKeyAlias());
  interceptor.setSecurementPassword(props.signingKeyPassword());
  interceptor.setSecurementSignatureCrypto(signingCrypto(props));
  interceptor.setSecurementSignatureKeyIdentifier("DirectReference");
  interceptor.setSecurementTimeToLive(300);
 
  // Inbound: reject anything that is not signed and timestamped.
  interceptor.setValidationActions("Timestamp Signature");
  interceptor.setValidationSignatureCrypto(trustCrypto(props));
  interceptor.setValidationTimeToLive(300);
 
  return interceptor;
}

setSecurementSignatureKeyIdentifier is worth understanding rather than copying. DirectReference embeds your whole certificate in the message, so the receiver does not need it installed in advance. IssuerSerial sends only the issuer and serial number, which is smaller but requires the receiver to already hold your certificate. Partners usually specify which one they expect, and getting it wrong produces a signature-verification failure that reads like a key problem.

The time-to-live values are a replay window. Five minutes is a common default and it assumes both clocks are roughly right. If the partner's clock drifts, you will see intermittent rejections that correlate with nothing in your own system — which is why the first thing to check on a mysterious signature failure is NTP on both sides, not the keystore.

Layer 3: the payload never opens in transit

The file arrives encrypted by the HSM. Your service moves it; it does not read it. This sounds obvious and yet the tempting shortcut — decrypt on receipt so the rest of the pipeline can work with plain data — is exactly what turns an ordinary service into one that is in scope for a much heavier set of controls.

Keep the encrypted blob sealed, store it as a blob, and pass the reference onward. The component that holds the key is the only one that decrypts, and it should be as small as you can make it. In practice this meant landing the file in blob storage under a content-addressed key, recording the metadata in PostgreSQL, and letting the downstream consumer fetch and decrypt on its own terms.

The webhook is the notification, not the work

Delivery is announced by webhook. The instinct is to do the ingestion inside the webhook handler, and it is wrong for a reason that only shows up under load: the caller has a timeout, and it will retry when you exceed it. If your handler is doing the download, the decryption check and the database write, a slow run produces a second delivery of the same file while the first is still in flight.

The handler should do three things and stop: authenticate the call, record the notification, return 202. Everything else happens on a worker.

DeliveryWebhookController.java
@PostMapping("/deliveries")
ResponseEntity<Void> onDelivery(@RequestBody DeliveryNotification notification) {
  // Natural key from the partner's own identifiers — never a UUID we generate.
  var key = IngestionKey.of(notification.batchId(), notification.fileId());
 
  boolean firstTime = ingestionLog.recordIfAbsent(key, notification);
  if (firstTime) {
    ingestionQueue.enqueue(key);
  }
 
  // 202 either way: a duplicate notification is a success, not an error.
  return ResponseEntity.accepted().build();
}

Returning 202 for a duplicate matters. If you return an error for a repeat delivery, the partner's retry logic treats it as a failed delivery, and some systems will escalate or halt the batch. A duplicate is not a failure — it is the delivery guarantee working as designed.

Idempotency, because you will be called twice

recordIfAbsent is doing the load-bearing work, and it has to be a single atomic operation against a unique constraint, not a SELECT followed by an INSERT. Two notifications arriving milliseconds apart will both pass the check-then-act version.

INSERT INTO ingestion_log (batch_id, file_id, received_at, payload)
VALUES (?, ?, now(), ?)
ON CONFLICT (batch_id, file_id) DO NOTHING

The natural key comes from the partner's identifiers. It is tempting to generate your own id on receipt, but then two notifications about the same file get two different ids and the deduplication does nothing. The key must be something both sides agree identifies the same real-world thing.

There is more to say about why a deduplication table alone is not enough once side effects enter the picture, which I have written up separately in idempotency keys are not enough when money moves.

What I would check before going live

A short list, drawn from the things that actually went wrong:

  1. Both certificates' expiry dates are in a calendar and an alert, measured from the certificate presented on the wire rather than from the file you think is deployed. Rotation across an organizational boundary deserves its own plan — see certificate rotation between two companies.
  2. Clock sync is monitored on your side. WS-Security timestamps make NTP a production dependency.
  3. A replayed message is rejected, and you have tested it by capturing a valid request and sending it twice.
  4. An unsigned message is rejected. Verify by removing the interceptor in a test and asserting the fault, so a future refactor that drops it fails loudly.
  5. The webhook handler is provably fast. Put a timer on it and alert well below the partner's timeout.
  6. The encrypted payload is never written to a log. Check the debug-level logging too, not just info.

None of this is exotic. It is just the part of enterprise integration that gets learned in production because nobody writes it down.

Written by Yassir Halaoui, Senior Fullstack Java / React Developer in Paris. Nine years across banking, payments and insurance, and two SaaS products built and operated solo. Get in touch.