SOAP in .NET without WCF: WS-Security, MTOM and ?wsdl as a route step

redb.Route

SOAP did not go away. If you sell airline tickets, you talk to Amadeus, Sabre and Travelport over SOAP. If you integrate with a bank, a government portal, an insurance backend, a telco billing system or almost any enterprise product bought before 2015, the contract is a WSDL and the wire is a <soap:Envelope>. WS-Security signatures, MTOM attachments, SOAP 1.1 next to SOAP 1.2, a soap:Fault on error: that world is alive, it pays the bills, and it is not migrating to REST because you asked nicely.

In .NET, calling it has become awkward. WCF as a full framework is gone. CoreWCF exists but every published version carries an unpatched crypto advisory, so pulling it in trades one problem for another. dotnet-svcutil generates a client from a WSDL at build time, but that is code generation glued to the side of your app, not an integration step. And hosting a SOAP endpoint, adding WS-Security by hand, or wiring MTOM tends to end in a pile of System.ServiceModel configuration nobody wants to own.

redb.Route.Soap takes a different route. SOAP becomes an ordinary step in a pipeline in your own .NET process. Call a service, get a typed reply. Host an endpoint, hand the body to a route, return a response. Everything is in-box: HttpClient, the shared Kestrel host, and System.Security.Cryptography.Xml for WS-Security. No WCF, no System.ServiceModel runtime, no vulnerable dependency, no separate gateway. Let us walk through how it is used, and why a native connector inside the ESB beats a codegen client or a standalone box.

SOAP in one minute

services.AddRedbRoute(route =>
{
    route.Services.AddRedbRouteSoap();
    route.AddRouteBuilder<MyRoutes>();
});
// Call a service
From("direct://get-fares")
    .To(Soap.Call("https://gds/air.svc").ConnectionFactory("amadeus").Operation("GetFares"));

// Host a SOAP endpoint
From(Soap.Listen("/svc/orders").Host("0.0.0.0").Port(4090))
    .Process(HandleOrder);

AddRedbRouteSoap() registers the soap and soaps schemes and shares one Kestrel receive server with every other HTTP-based connector in the process. In the default Payload mode the message body is the XML of <soap:Body>: send a fragment, receive a fragment, no code generation, works against any service.

The endpoint is a string (or a fluent builder)

Every endpoint reads two equivalent ways. Some people prefer the type-safe builder, some prefer a plain URI they can drop into config. They compile to the same thing.

// Fluent
.To(Soap.Call("https://gds/air.svc").ConnectionFactory("amadeus").Operation("GetFares"))

// String URI (identical)
.To("soaps://gds/air.svc?connectionFactory=amadeus&operation=GetFares")

The consumer side is the same story:

From(Soap.Listen("/svc/orders").Host("0.0.0.0").Port(4090).ConnectionFactory("orders"))
// or
From("soap:/svc/orders?host=0.0.0.0&port=4090&connectionFactory=orders")

soap is HTTP, soaps is HTTPS. The producer address is soap[s]://host/path; the consumer is soap:/path?host=&port= with the path kept intact. Because the URI is just a string, the endpoint can come from appsettings.json and change per environment without touching code.

A service binding is one object, not a scatter of parameters

Certificates, credentials, the SOAP version and the data format never live in a URI. They live on a SoapConnectionFactory, registered once by name. Routes reference it with .ConnectionFactory("name").

context.AddToRegistry("amadeus", new SoapConnectionFactory
{
    EndpointUrl = "https://gds/air.svc",
    SoapVersion = SoapVersion.Soap11,
    DefaultAction = "urn:GetFares",

    // WS-Security material, all optional
    SigningCert = ourPfx,      // our cert + PRIVATE key: signs outgoing, decrypts incoming
    EncryptCert = partnerCer,  // partner's PUBLIC cert: encrypts outgoing, authenticates their signature
    Username = "svc", Password = "secret",   // UsernameToken

    Mtom = true,               // MTOM/XOP attachments
    Wsdl = "contracts/air.wsdl",              // published on GET ?wsdl (consumer)
});

The password field is marked sensitive, so it is redacted in logs and in the runtime dashboard. Move from staging to production by swapping the registered object, not by editing the routes.

Three ways the route sees the message

camel-cxf has data formats; so does this connector, on SoapConnectionFactory.DataFormat.

Payload (default). The body is the inner <soap:Body> XML. Works with any service, no types, no codegen.

Message. A transparent proxy: the body is the whole envelope, in and out. Log SOAP traffic, forward it untouched, inspect it.

Pojo. Typed request and response objects via XmlSerializer, the .NET analogue of JAXB document/literal. The DTOs are ordinary XML-serializable types and may be generated from a WSDL with dotnet-svcutil.

context.AddToRegistry("air", new SoapConnectionFactory {
    EndpointUrl = "https://gds/air.svc",
    DataFormat  = SoapDataFormat.Pojo,
    ResponseType = typeof(GetFaresResponse),
});

From("direct://q")
    .Process(e => e.In.Body = new GetFaresRequest { Route = "JFK-LHR" })
    .To(Soap.Call("https://gds/air.svc").ConnectionFactory("air"));
// e.Out.Body is now a GetFaresResponse

You choose the level of typing. A generic pass-through pipeline stays in Payload; a service with a fixed contract goes Pojo and works with real objects.

WS-Security that authenticates, in-box

WS-Security is where SOAP integrations usually get stuck. Here it is three fields on the factory and it runs on System.Security.Cryptography.Xml, no CoreWCF anywhere near it.

  • UsernameToken: set Username (and Password); the producer prepends a <wsse:Security> header, the consumer surfaces the credentials to the route.
  • XML-Signature: with SigningCert set, the producer signs the <soap:Body> (Exclusive C14N, SHA-256, embedded X.509); the consumer verifies.
  • XML-Encryption: with EncryptCert set, the producer encrypts the Body to the partner; the consumer decrypts with its private key.

The verification is the part that matters. When the partner certificate is configured, signature verification is authenticated: the signer must be that exact certificate, and the signature must cover the Body, so a forged self-signed signature or a signature over a decoy element is rejected. That is the difference between "the XML was not tampered" and "this message is really from the partner", and it is easy to get wrong.

The encryption goes out in the layout real WS-Security stacks produce and expect: the EncryptedKey in the <wsse:Security> header, joined by a ReferenceList to the EncryptedData in the Body, AES-256 under an RSA-OAEP key wrap. It is not a private dialect. An independent crypto stack decrypts it end to end (more on that below).

Binary attachments: MTOM

Large binaries do not belong base64-inflated inside the XML. Set Mtom = true and attachments travel as multipart/related with XOP, on a side plane so the Body contract stays clean, the same way Camel keeps them on an AttachmentMessage.

var msg = new Message(
    "<Upload xmlns=\"urn:svc\"><file>" +
    "<xop:Include xmlns:xop=\"http://www.w3.org/2004/08/xop/include\" href=\"cid:doc-1\"/></file></Upload>");
msg.Headers[SoapHeaders.Attachments] =
    new List<SoapAttachment> { new("doc-1", "application/pdf", pdfBytes) };
await producer.Process(new Exchange(msg));

On the receiving side the route reads inbound attachments off redbSoap.attachments, and replies with its own list. The connector will not silently echo the caller's attachments back.

Publish your WSDL on ?wsdl

.NET Core has no runtime WSDL import, by design, so the connector does not pretend to. What it does do is publish a contract. Point SoapConnectionFactory.Wsdl at a file or inline XML on a consumer, and it is served on GET {path}?wsdl with the <soap:address> rewritten to the address the caller actually reached you on. Pair it with Pojo mode for typed request and response, and you have a contract-first SOAP service.

SOAP as a controller

Here is the part that does not exist in a codegen client. SOAP is a first-class controller transport in redb.Route, next to HTTP, gRPC and SignalR. Write a controller, dispatch by operation.

[Route("air")]
public class AirController : RedbController
{
    // Method name = SOAP operation; the XML body binds in, the typed reply serializes out.
    public Task<GetFaresResponse> GetFares([FromBody] GetFares req)
        => Task.FromResult(new GetFaresResponse { Price = Quote(req.Route) });

    [SoapOperation("HealthCheck")]           // explicit name when it differs from the method
    public string Health() => "ok";
}

// Behind a SOAP endpoint:
From(Soap.Listen("/svc/air").Host("0.0.0.0").Port(4090))
    .RedbSoapController<AirController>();

The operation the SOAP consumer parsed from the request maps to a method, the XML body binds to the parameter, and the return value goes back as the response. No HTTP attributes, no manual envelope handling, a soap:Fault on error. The same controller class works behind any transport, so a service can speak SOAP and REST at once with one implementation.

Where it is used, and why native beats a gateway

The scenarios are the boring, load-bearing ones. A travel platform querying GDS fares over SOAP and republishing them as JSON. A bank integration that receives a signed SOAP request, validates it, and writes to a ledger. A government or healthcare portal with a strict WSDL contract you must serve exactly. A legacy ERP that only speaks SOAP 1.1 with a UsernameToken.

With a codegen client or a standalone gateway, SOAP lives beside your integration. You generate a proxy, or you run a separate process, and then you still have to move the message into your actual pipeline, translate the fault, correlate the trace across a boundary. With redb.Route.Soap it is one step of a route:

From(Soap.Listen("/svc/orders").Host("0.0.0.0").Port(8443).ConnectionFactory("self"))
    .Validate(Body().Matches(orderSchema))
    .Unmarshal("xml").Marshal("json")
    .To("kafka://orders")
    .Process(e => e.Out.Body = "<Ack xmlns=\"urn:svc\">accepted</Ack>");

One process, one deployment, one trace. The SOAP request lands, gets validated, transformed, dropped into Kafka, and the partner gets an envelope back, and all of it shows up in the same OpenTelemetry span tree as every other step. A Client span on the call, a Server span on the endpoint, endpoint statistics, secret redaction: the same cross-cutting behavior every other connector in the family has.

Interop proven, not claimed

A SOAP connector that only talks to itself is not a SOAP connector. This one is checked against an independent stack, the Node.js soap library, which shares no code with it, in both directions: our producer against their server, their client against our consumer, plain SOAP and MTOM. And the WS-Security encryption is decrypted end to end by an independent crypto stack (Node's OpenSSL) straight off our envelope: RSA-OAEP key unwrap, AES-256-CBC, the XML-Encryption padding stripped as the spec mandates. The wire is standard, and that is demonstrated, not asserted.

Honest boundaries

Runtime WSDL import (a client introspecting a remote WSDL on the fly) is absent from .NET Core by design; the connector serves a static WSDL contract and pairs it with Pojo types. WSDL is not generated from CLR types by reflection; contract-first is the model. A few rare WS-* variants (external CipherReference, non-OAEP key transport, UsernameToken PasswordDigest) are not handled yet. None of that blocks the mainline: SOAP 1.1 and 1.2, faults, the two header planes, WS-Security with authenticated signatures and standard-layout encryption, MTOM, WSDL publishing, and controllers.

FAQ

Do I need CoreWCF or System.ServiceModel? No. The baseline is HttpClient, the shared Kestrel host, and System.Security.Cryptography.Xml. CoreWCF was deliberately avoided because every version carries an unpatched crypto advisory.

SOAP 1.1 or 1.2? Both. SoapVersion drives the Content-Type and the SOAPAction placement; faults are parsed for both, regardless of the namespace prefix a WCF or CXF peer uses.

Can one service speak SOAP and REST? Yes, through the controller transport: the same RedbController sits behind Soap.Listen(...) and Http.Listen(...).

Where do certificates go? On the SoapConnectionFactory, as X509Certificate2. The password is redacted in logs and the dashboard.

Install

dotnet add package redb.Route.Soap

The package is redb.Route.Soap on NuGet; the source and the full DSL reference are in the connector README. SOAP is one more transport in the redb.Route family, alongside Kafka, RabbitMQ, AS2, IBM MQ and the rest: the same From → … → To, the same EIPs, the same observability. The only difference is that the message on the wire is a <soap:Envelope> a twenty-year-old enterprise system is waiting for.

If this was useful — a ⭐ on GitHub helps others find it.


More of my writing: redbase.app/articles, and on dev.to.