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
<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.
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.
| Message | Usually means |
|---|---|
The signature or decryption was invalid | Canonicalization mismatch, or something altered the message in transit |
An error was discovered processing the <wsse:Security> header | Generic wrapper; the cause is nested, read the full stack |
Security header is missing | Interceptor not on the chain, or on the wrong chain |
Cannot find key for alias | Merlin property typo, or the alias is case-sensitive and you assumed otherwise |
Invalid timestamp: message expired | Clock skew, not a certificate problem |
unable to find valid certification path | Trust 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:
@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.