Spring Boot 4 / Spring Web Services / XML Signature

Signed SOAP between Spring services

A complete service-to-service demo: a public REST facade, a generated SOAP contract, XML Signature in the WS-Security header, signed responses, and a signed callback from the provider back to the client.

Demo runtime flow REST facade to signed SOAP
Caller User or Postman GET /api/content/article-1
client-service REST facade WebServiceTemplate signs SOAP
provider-service Content SOAP endpoint WSS4J validates request
Provider action delivery notification same WSS4J configuration
Callback URL client-service /ws NotifyContentDelivered endpoint
Contract XSD + generated WSDL
Signature SOAP Body + Timestamp
Keys local PKCS12 stores
Logs REST and SOAP through Logbook
AI assistance disclosure. The demo code and this article were prepared with assistance from AI tooling, including OpenAI Codex. The implementation is intended as an educational example and should be reviewed before adapting it to production systems.

01 / Goal

What this demo builds

The repository demonstrates service-to-service communication where the message itself carries the proof of integrity and sender identity. The SOAP envelope is signed in the wsse:Security header, so the receiver validates the XML payload instead of trusting only the transport channel.

There are two Spring Boot applications. client-service exposes a REST API only to make the demo easy to call from a browser, curl, or Postman. That REST API is not part of the SOAP security model. Internally, the client sends signed SOAP requests to provider-service. The provider returns signed SOAP responses and, after a successful GetContent, calls a signed SOAP callback on the client.

02 / Contract first

The shared model comes from XSD

The soap-contracts module contains XSD schemas for the provider API and the callback API. Maven generates JAXB classes from those schemas, and both services use the same model classes for endpoints and clients. The WSDL is generated by Spring-WS from the XSD, so there is no manually maintained WSDL duplicate.

soap-contracts/src/main/resources/xsd/content.xsd
<xs:element name="GetContentRequest">
  <xs:complexType>
    <xs:sequence>
      <xs:element name="contentId" type="xs:string"/>
      <xs:element name="callbackUrl" type="xs:string" minOccurs="0"/>
    </xs:sequence>
  </xs:complexType>
</xs:element>

<xs:element name="GetContentResponse">
  <xs:complexType>
    <xs:sequence>
      <xs:element name="contentId" type="xs:string"/>
      <xs:element name="title" type="xs:string"/>
      <xs:element name="body" type="xs:string"/>
      <xs:element name="category" type="xs:string"/>
      <xs:element name="servedAt" type="xs:string"/>
    </xs:sequence>
  </xs:complexType>
</xs:element>

03 / Spring-WS

Publishing WSDL with DefaultWsdl11Definition

Each SOAP application registers Spring-WS MessageDispatcherServlet under /ws/*. The provider publishes /ws/content.wsdl. The client publishes /ws/callback.wsdl for the provider callback.

ProviderSoapConfiguration
@Bean
ServletRegistrationBean<MessageDispatcherServlet> messageDispatcherServlet(
        ApplicationContext applicationContext) {
    MessageDispatcherServlet servlet = new MessageDispatcherServlet();
    servlet.setApplicationContext(applicationContext);
    servlet.setTransformWsdlLocations(true);
    return new ServletRegistrationBean<>(servlet, "/ws/*");
}

@Bean(name = "content")
DefaultWsdl11Definition contentWsdl(XsdSchema contentSchema) {
    DefaultWsdl11Definition wsdl = new DefaultWsdl11Definition();
    wsdl.setPortTypeName("ContentPort");
    wsdl.setLocationUri("/ws");
    wsdl.setTargetNamespace("urn:demo:provider:content:v1");
    wsdl.setSchema(contentSchema);
    return wsdl;
}

04 / Trust

Each service owns a signing key and trusts the other certificate

The demo uses local PKCS12 stores. The client signs requests with its private key. The provider validates them with the client certificate in provider-truststore.p12. The provider signs responses and callbacks with its own private key. The client validates them through client-truststore.p12.

scripts/generate-demo-keys.sh
./scripts/generate-demo-keys.sh

# Generated files:
# provider-service/src/main/resources/security/provider-signing.p12
# provider-service/src/main/resources/security/provider-truststore.p12
# client-service/src/main/resources/security/client-signing.p12
# client-service/src/main/resources/security/client-truststore.p12

05 / WS-Security

Signing and validation with Wss4jSecurityInterceptor

The central Spring-WS integration point is Wss4jSecurityInterceptor. On the server side it is registered as an endpoint interceptor. On the client side it is attached to WebServiceTemplate. The demo requires both Timestamp and Signature in every SOAP exchange.

Wss4jSecurityInterceptor
static final String SOAP_BODY_AND_TIMESTAMP =
    "{}{http://schemas.xmlsoap.org/soap/envelope/}Body;"
  + "{}{http://docs.oasis-open.org/wss/2004/01/"
  + "oasis-200401-wss-wssecurity-utility-1.0.xsd}Timestamp";

@Bean
Wss4jSecurityInterceptor clientSecurityInterceptor() throws Exception {
    Wss4jSecurityInterceptor interceptor = new Wss4jSecurityInterceptor();
    interceptor.setValidationActions("Timestamp Signature");
    interceptor.setValidationSignatureCrypto(trustedProviderCrypto());
    interceptor.setValidationTimeToLive(300);
    interceptor.setTimestampStrict(true);

    interceptor.setSecurementActions("Timestamp Signature");
    interceptor.setSecurementUsername("client");
    interceptor.setSecurementPassword("changeit");
    interceptor.setSecurementSignatureCrypto(clientSigningCrypto());
    interceptor.setSecurementSignatureKeyIdentifier("DirectReference");
    interceptor.setSecurementSignatureParts(SOAP_BODY_AND_TIMESTAMP);
    interceptor.setSecurementTimeToLive(300);
    interceptor.setSecurementMustUnderstand(true);
    return interceptor;
}

06 / SOAP client

WebServiceTemplateBuilder wires marshalling, security, and logging

The client does not instantiate WebServiceTemplate directly. It uses Spring Boot's WebServiceTemplateBuilder, sets JAXB marshalling, points to the provider URI, attaches the WSS4J interceptor, and uses an HTTP sender that is also intercepted by Logbook.

ClientSoapConfiguration
@Bean
WebServiceTemplate providerWebServiceTemplate(
        WebServiceTemplateBuilder builder,
        Jaxb2Marshaller marshaller,
        Wss4jSecurityInterceptor securityInterceptor,
        ClientHttpRequestMessageSender logbookSoapMessageSender,
        @Value("${provider.soap.uri}") String providerSoapUri) {
    return builder
            .setMarshaller(marshaller)
            .setUnmarshaller(marshaller)
            .setDefaultUri(providerSoapUri)
            .messageSenders(logbookSoapMessageSender)
            .interceptors(new ClientInterceptor[] { securityInterceptor })
            .build();
}
ProviderContentClient
public GetContentResponse getContent(String contentId, String callbackUrl) {
    GetContentRequest request = new GetContentRequest();
    request.setContentId(contentId);
    request.setCallbackUrl(callbackUrl);
    return (GetContentResponse) providerWebServiceTemplate
            .marshalSendAndReceive(request);
}

07 / Endpoints

REST facade, SOAP provider, and signed callback

The REST controller exists for testing and demonstration only. It gives a simple way to trigger the SOAP flow without a SOAP UI client. The actual service-to-service contract is the Spring-WS SOAP contract, not the REST API.

ContentEndpoint
@Endpoint
public class ContentEndpoint {
    private static final String NAMESPACE = "urn:demo:provider:content:v1";

    @PayloadRoot(namespace = NAMESPACE, localPart = "GetContentRequest")
    @ResponsePayload
    public GetContentResponse getContent(@RequestPayload GetContentRequest request) {
        OffsetDateTime servedAt = OffsetDateTime.now();
        ContentItem item = contentCatalog.getById(request.getContentId());
        callbackClient.notifyDelivered(request.getCallbackUrl(), item, servedAt);

        GetContentResponse response = new GetContentResponse();
        response.setContentId(item.contentId());
        response.setTitle(item.title());
        response.setBody(item.body());
        response.setCategory(item.category());
        response.setServedAt(servedAt.toString());
        return response;
    }
}
ContentRestController
@RestController
@RequestMapping("/api")
public class ContentRestController {

    @GetMapping("/content/{id}")
    public ContentResponse getContent(@PathVariable String id) {
        GetContentResponse response = providerContentClient
                .getContent(id, callbackUrl());
        return new ContentResponse(
                response.getContentId(),
                response.getTitle(),
                response.getBody(),
                response.getCategory(),
                response.getServedAt());
    }
}

08 / Observability

REST and SOAP are logged through one mechanism

The demo uses Zalando Logbook for request and response logging. Incoming REST and SOAP traffic is visible through the servlet filter. Outgoing SOAP traffic is visible because ClientHttpRequestMessageSender uses LogbookClientHttpRequestInterceptor.

application.properties
logging.level.org.zalando.logbook.Logbook=TRACE
logbook.format.style=http
logbook.strategy=default
logbook.write.max-body-size=-1
logbook.filters.body.default-enabled=false
SOAP client sender with Logbook
@Bean
ClientHttpRequestMessageSender logbookSoapMessageSender(
        LogbookClientHttpRequestInterceptor logbookInterceptor) {
    InterceptingClientHttpRequestFactory requestFactory =
            new InterceptingClientHttpRequestFactory(
                    new SimpleClientHttpRequestFactory(),
                    List.of(logbookInterceptor));
    return new ClientHttpRequestMessageSender(requestFactory);
}

JSON pretty printing is handled by Logbook. XML pretty printing is done by a small BodyFilter based on the JDK DOM parser and Transformer. This keeps Logbook as the single HTTP request/response logging layer. Spring-WS MessageTracing is intentionally not enabled.

09 / Runtime evidence

Full demo logs from client and provider

The page loads the full application logs from static files bundled with the article. They show the REST request, outgoing signed SOAP request, incoming signed SOAP response, provider-side request validation, and the signed callback exchange.

logs/client.log
Loading client log...
logs/provider.log
Loading provider log...

10 / Run it

How to run the demo locally

The repository contains Maven Wrapper, so a local Maven installation is not required. The .p12 files are not committed. Generate them locally before running the services.

Terminal
git clone https://github.com/emlagowski/web-service-spring.git
cd web-service-spring

./scripts/generate-demo-keys.sh
./mvnw verify

./mvnw -pl provider-service spring-boot:run
./mvnw -pl client-service spring-boot:run
Demo REST calls
curl http://localhost:8081/api/content/article-1
curl "http://localhost:8081/api/content?category=spring"
curl http://localhost:8081/api/callback-events

11 / Production notes

What changes outside a demo

This project keeps configuration close to the code to make the Spring-WS mechanics visible. In production, signing keys should come from a controlled key management process, passwords should not live in source control, certificate rotation should be planned, and signature algorithms must match current security policy.

The implementation pattern stays the same: contract-first XSD, generated JAXB model, Spring-WS endpoints, Wss4jSecurityInterceptor on both sides, and an explicit truststore defining whose signatures each service accepts.