Skip to content
Yassir Halaoui

Secure integration · 5 min read

WS-Security in 2026: making a 2004 spec work with Java 21

By Yassir HalaouiPublished

In short

WS-Security still works on Java 21, but the ecosystem moved underneath it: JAXB left the JDK, javax became jakarta, and the XML signature stack is unforgiving about canonicalization and namespace handling. A working 2026 setup is Spring Web Services with WSS4J for the security header, explicit jakarta.xml.bind dependencies, exclusive canonicalization, and a test that verifies against the counterparty's real certificate rather than a self-signed one.

WS-Security is from 2004. Java 21 is from 2023. Most of the pain in getting them to work together is not the specification — it is that the Java XML ecosystem moved underneath it twice while nobody was updating the tutorials.

Here is what a working setup looks like now, and which of the classic failure messages mean what.

What changed underneath it

Three shifts account for nearly every broken example you will find online.

JAXB left the JDK. It was removed in Java 11. Anything relying on javax.xml.bind needs an explicit dependency now, and on a modern stack that dependency is jakarta.xml.bind-api with a Jakarta implementation — not the old javax artifact, which will resolve, compile, and then fail at runtime with a context path error that reads like your model classes are wrong.

javax became jakarta. Spring Boot 3 moved wholesale. This matters for WS-Security because the interceptor package changed: it is org.springframework.ws.soap.security.wss4j2.Wss4jSecurityInterceptor — the wss4j2 in the middle is WSS4J 2.x, and the older wss4j package does not exist in Spring WS 4.

Security defaults hardened. Newer JDKs disable weak algorithms by default in java.security. SHA-1 signatures are the common casualty. If a partner is still on rsa-sha1, you will get a failure that names the algorithm, and the correct response is to ask them to move rather than to re-enable it — though you should know that jdk.xml.dsig.secureValidationPolicy is what is rejecting it, so you can explain precisely what is happening.

A dependency set that works

pom.xml
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web-services</artifactId>
</dependency>
 
<!-- WS-Security header handling -->
<dependency>
  <groupId>org.apache.wss4j</groupId>
  <artifactId>wss4j-ws-security-dom</artifactId>
</dependency>
 
<!-- JAXB is no longer in the JDK. Both halves are required. -->
<dependency>
  <groupId>jakarta.xml.bind</groupId>
  <artifactId>jakarta.xml.bind-api</artifactId>
</dependency>
<dependency>
  <groupId>org.glassfish.jaxb</groupId>
  <artifactId>jaxb-runtime</artifactId>
  <scope>runtime</scope>
</dependency>

The jaxb-runtime at runtime scope is the one people leave out. Everything compiles without it. The failure appears the first time you marshal a message.

The Crypto configuration

WSS4J finds keys through a Crypto instance, configured with Merlin properties. This is one of those APIs where the property names are load-bearing and a typo produces a null rather than an error.

WsSecurityCryptoConfig.java
private Crypto signingCrypto(SoapProperties props) throws Exception {
  var merlin = new Properties();
  merlin.setProperty(
      "org.apache.wss4j.crypto.provider",
      "org.apache.wss4j.common.crypto.Merlin");
  merlin.setProperty(
      "org.apache.wss4j.crypto.merlin.keystore.type", "PKCS12");
  merlin.setProperty(
      "org.apache.wss4j.crypto.merlin.keystore.file",
      props.signingKeyStorePath().toString());
  merlin.setProperty(
      "org.apache.wss4j.crypto.merlin.keystore.password",
      new String(props.signingKeyStorePassword()));
 
  return CryptoFactory.getInstance(merlin);
}

Keep the signing store and the trust store separate here as well. The crypto used for securementSignatureCrypto holds your private key; the one used for validationSignatureCrypto holds the partner's chain. Merging them works right up until a rotation, and then it does not.

Canonicalization, and why signatures fail for no reason

This is the part that costs a day if you have not met it before.

An XML signature is computed over a canonical form of the signed content, not over the bytes on the wire. Canonicalization normalizes whitespace, attribute order, and namespace declarations so that two semantically identical documents produce the same signature.

Which means: any component that touches the XML between signing and verification can invalidate the signature without changing the meaning. A proxy that pretty-prints. A logging filter that reserializes the body to inspect it. A gateway that adds a namespace prefix.

Two practical consequences.

Use exclusive canonicalization (http://www.w3.org/2001/10/xml-exc-c14n#). Inclusive canonicalization pulls in namespace declarations from ancestor elements, so a message that verifies standalone can fail once it is wrapped in a different envelope. Exclusive is the safe default and what most partners expect.

Never reserialize the message for logging. Log the raw bytes or log nothing. A debugging filter that parses the body to pretty-print it, and then hands the reparsed document onward, is a signature failure that only reproduces when debug logging is on — a genuinely miserable class of bug.

Reading the failures

The error messages are unhelpful in a consistent way, which at least makes them learnable.

MessageUsually means
The signature or decryption was invalidCanonicalization mismatch, or something altered the message in transit
An error was discovered processing the <wsse:Security> headerGeneric wrapper; the cause is nested, read the full stack
Security header is missingInterceptor not on the chain, or on the wrong chain
Cannot find key for aliasMerlin property typo, or the alias is case-sensitive and you assumed otherwise
Invalid timestamp: message expiredClock skew, not a certificate problem
unable to find valid certification pathTrust store, not key store

The last two are worth internalizing because they are so frequently misdiagnosed. An expired-timestamp error sends people to check certificate validity dates; the actual problem is NTP. A certification-path error sends people to check their own key; the actual problem is that they never installed the partner's issuing CA.

Testing it honestly

The test that matters is not "does my signature verify against my own certificate". That passes trivially and proves nothing.

Verify against the partner's real certificate, in a test that runs in CI:

SignatureVerificationTest.java
@Test
void partnerResponseVerifiesAgainstTheirPublishedCertificate() {
  var response = readFixture("partner-signed-response.xml");
 
  assertThatCode(() -> interceptor.validateMessage(response, null))
      .doesNotThrowAnyException();
}
 
@Test
void unsignedMessageIsRejected() {
  var response = readFixture("partner-response-without-security-header.xml");
 
  assertThatThrownBy(() -> interceptor.validateMessage(response, null))
      .isInstanceOf(WsSecurityValidationException.class);
}

The second test is the important one, and it is the one people skip. Without it, a future refactor that drops the interceptor from the chain turns your service into one that accepts unsigned messages, and every existing test still passes.

Capture a real signed response as a fixture during integration testing and keep it. It is the only artifact that tells you whether a library upgrade broke verification, and it costs nothing to hold onto.

Where this sits

WS-Security is the message layer. It sits inside mutual TLS, which authenticates the connection, and outside the payload, which stays encrypted end to end — the full picture is in ingesting HSM-encrypted files over SOAP with mTLS and WS-Security.

None of this is difficult once you have seen it. It is just old, and the people who learned it in 2008 have mostly stopped writing about it, while the runtime underneath has changed enough that their posts no longer apply.

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.

← More on secure integration