This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch camel-4.18.x
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/camel-4.18.x by this push:
     new db340d3f0b9f [backport camel-4.18.x] CAMEL-25578: camel-lra - verify 
participant callbacks with an optional shared secret (#27811)
db340d3f0b9f is described below

commit db340d3f0b9f010decdd1365b66e1acbeff077a1
Author: Andrea Cosentino <[email protected]>
AuthorDate: Wed Oct 14 13:45:35 2026 +0200

    [backport camel-4.18.x] CAMEL-25578: camel-lra - verify participant 
callbacks with an optional shared secret (#27811)
    
    Co-Authored-By: Claude Opus 4.6 <[email protected]>
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../main/camel-main-configuration-metadata.json    |   1 +
 .../apache/camel/catalog/main/sensitive-keys.json  |   1 +
 .../service/lra/LRASagaServiceConfigurer.java      |   6 +
 components/camel-lra/src/main/docs/lra.adoc        |   2 +
 .../camel/service/lra/LRACallbackSigner.java       |  94 +++++++
 .../org/apache/camel/service/lra/LRAClient.java    |  36 ++-
 .../org/apache/camel/service/lra/LRAConstants.java |   2 +
 .../apache/camel/service/lra/LRASagaRoutes.java    |  92 ++++++-
 .../apache/camel/service/lra/LRASagaService.java   |  58 ++++
 .../apache/camel/service/lra/LRAUrlBuilder.java    |   8 +-
 .../camel/service/lra/LRACallbackSignerTest.java   | 139 ++++++++++
 .../apache/camel/service/lra/LRAClientTest.java    | 108 ++++++++
 .../lra/LRASagaRoutesCallbackSignatureTest.java    | 301 +++++++++++++++++++++
 .../camel/service/lra/LRASagaRoutesTest.java       |  30 ++
 .../lra/LRASagaRoutesUnsignedCallbackTest.java     | 136 ++++++++++
 .../camel/service/lra/LRASagaServiceTest.java      |  30 ++
 .../camel/service/lra/LRASignedCallbacksIT.java}   |  28 +-
 .../camel/service/lra/LRAUrlBuilderTest.java       | 110 ++++++++
 .../src/main/docs/modules/eips/pages/saga-eip.adoc |  28 ++
 .../main/LraConfigurationPropertiesConfigurer.java |   7 +
 .../camel-main-configuration-metadata.json         |   1 +
 core/camel-main/src/main/docs/main.adoc            |   3 +-
 .../camel/main/LraConfigurationProperties.java     |  24 ++
 .../java/org/apache/camel/util/SensitiveUtils.java |   2 +
 .../maven/packaging/UpdateSensitizeHelper.java     |   4 +-
 25 files changed, 1221 insertions(+), 30 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/camel-main-configuration-metadata.json
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/camel-main-configuration-metadata.json
index 2f8df635ce4e..7a4973537e9b 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/camel-main-configuration-metadata.json
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/camel-main-configuration-metadata.json
@@ -190,6 +190,7 @@
     { "name": "camel.health.registryEnabled", "required": false, 
"description": "Whether registry health check is enabled", "sourceType": 
"org.apache.camel.main.HealthConfigurationProperties", "type": "boolean", 
"javaType": "java.lang.Boolean", "defaultValue": true, "secret": false },
     { "name": "camel.health.routesEnabled", "required": false, "description": 
"Whether routes health check is enabled", "sourceType": 
"org.apache.camel.main.HealthConfigurationProperties", "type": "boolean", 
"javaType": "java.lang.Boolean", "defaultValue": true, "secret": false },
     { "name": "camel.lra.allowedCoordinatorUrls", "required": false, 
"description": "Comma-separated URLs, including the context path, of further 
LRA coordinators whose LRAs may be joined, completed or compensated. By 
default, only an LRA hosted by the configured coordinator is accepted. Use it 
when participants reach the same coordinator under different URLs, for example 
with different host names.", "sourceType": 
"org.apache.camel.main.LraConfigurationProperties", "type": "string", "jav [...]
+    { "name": "camel.lra.callbackSecret", "required": false, "description": 
"Secret used to sign the participant callback URLs that are enlisted with the 
LRA coordinator, and to verify them when the coordinator calls back. When set, 
a compensation or completion callback with a missing or invalid signature is 
rejected with HTTP 403. All the instances of a participant must use the same 
secret.", "sourceType": "org.apache.camel.main.LraConfigurationProperties", 
"type": "string", "javaType": [...]
     { "name": "camel.lra.coordinatorContextPath", "required": false, 
"description": "The context-path for the LRA coordinator. Is default 
\/lra-coordinator", "sourceType": 
"org.apache.camel.main.LraConfigurationProperties", "type": "string", 
"javaType": "java.lang.String", "defaultValue": "\/lra-coordinator", "secret": 
false },
     { "name": "camel.lra.coordinatorUrl", "required": false, "description": 
"The URL for the LRA coordinator service that orchestrates the transactions", 
"sourceType": "org.apache.camel.main.LraConfigurationProperties", "type": 
"string", "javaType": "java.lang.String", "secret": false },
     { "name": "camel.lra.enabled", "required": false, "description": "To 
enable Saga LRA", "sourceType": 
"org.apache.camel.main.LraConfigurationProperties", "type": "boolean", 
"javaType": "boolean", "defaultValue": false, "secret": false },
diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/sensitive-keys.json
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/sensitive-keys.json
index c39bd3412a60..182e107489ab 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/sensitive-keys.json
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/sensitive-keys.json
@@ -19,6 +19,7 @@
   "azureclientsecret",
   "blobaccesskey",
   "blobstoragesharedkeycredential",
+  "callbacksecret",
   "certresourcepassword",
   "clientid",
   "clientsecret",
diff --git 
a/components/camel-lra/src/generated/java/org/apache/camel/service/lra/LRASagaServiceConfigurer.java
 
b/components/camel-lra/src/generated/java/org/apache/camel/service/lra/LRASagaServiceConfigurer.java
index cba17a6be24b..1f09e9014471 100644
--- 
a/components/camel-lra/src/generated/java/org/apache/camel/service/lra/LRASagaServiceConfigurer.java
+++ 
b/components/camel-lra/src/generated/java/org/apache/camel/service/lra/LRASagaServiceConfigurer.java
@@ -25,6 +25,8 @@ public class LRASagaServiceConfigurer extends 
org.apache.camel.support.component
         switch (ignoreCase ? name.toLowerCase() : name) {
         case "allowedcoordinatorurls":
         case "allowedCoordinatorUrls": 
target.setAllowedCoordinatorUrls(property(camelContext, java.lang.String.class, 
value)); return true;
+        case "callbacksecret":
+        case "callbackSecret": target.setCallbackSecret(property(camelContext, 
java.lang.String.class, value)); return true;
         case "camelcontext":
         case "camelContext": target.setCamelContext(property(camelContext, 
org.apache.camel.CamelContext.class, value)); return true;
         case "coordinatorcontextpath":
@@ -44,6 +46,8 @@ public class LRASagaServiceConfigurer extends 
org.apache.camel.support.component
         switch (ignoreCase ? name.toLowerCase() : name) {
         case "allowedcoordinatorurls":
         case "allowedCoordinatorUrls": return java.lang.String.class;
+        case "callbacksecret":
+        case "callbackSecret": return java.lang.String.class;
         case "camelcontext":
         case "camelContext": return org.apache.camel.CamelContext.class;
         case "coordinatorcontextpath":
@@ -64,6 +68,8 @@ public class LRASagaServiceConfigurer extends 
org.apache.camel.support.component
         switch (ignoreCase ? name.toLowerCase() : name) {
         case "allowedcoordinatorurls":
         case "allowedCoordinatorUrls": return 
target.getAllowedCoordinatorUrls();
+        case "callbacksecret":
+        case "callbackSecret": return target.getCallbackSecret();
         case "camelcontext":
         case "camelContext": return target.getCamelContext();
         case "coordinatorcontextpath":
diff --git a/components/camel-lra/src/main/docs/lra.adoc 
b/components/camel-lra/src/main/docs/lra.adoc
index 9ff6b4708fae..07724aaa4b91 100644
--- a/components/camel-lra/src/main/docs/lra.adoc
+++ b/components/camel-lra/src/main/docs/lra.adoc
@@ -13,6 +13,8 @@
 
 The LRA module provides bindings of the xref:eips:saga-eip.adoc[Saga EIP] with 
any https://github.com/eclipse/microprofile-lra[MicroProfile compatible LRA 
Coordinator].
 
+The configuration of the LRA Saga Service, including how to sign the callbacks 
that the LRA coordinator sends to the participant, is described in the 
xref:eips:saga-eip.adoc[Saga EIP] page.
+
 Maven users will need to add the following dependency to their `pom.xml`
 for this component:
 
diff --git 
a/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRACallbackSigner.java
 
b/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRACallbackSigner.java
new file mode 100644
index 000000000000..a607fa9b7550
--- /dev/null
+++ 
b/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRACallbackSigner.java
@@ -0,0 +1,94 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.service.lra;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.MessageDigest;
+import java.util.Base64;
+import java.util.Map;
+import java.util.StringJoiner;
+import java.util.TreeMap;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+
+/**
+ * Signs the participant callback URLs enlisted with the LRA coordinator, and 
verifies the signature when the
+ * coordinator calls them back, using HMAC-SHA256 with the callback secret of 
the LRA saga service.
+ * <p>
+ * The signature covers the callback path and every query parameter of the 
URL: the step endpoint URIs and the saga
+ * options. Parameters are sorted and encoded individually, so the signature 
does not depend on the order or the
+ * encoding used by the coordinator when it calls the URL back.
+ */
+final class LRACallbackSigner {
+
+    private static final String ALGORITHM = "HmacSHA256";
+
+    private final SecretKeySpec key;
+
+    LRACallbackSigner(String secret) {
+        this.key = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), 
ALGORITHM);
+    }
+
+    /**
+     * Computes the signature of a callback URL.
+     *
+     * @param  callbackPath the participant path the URL points to (compensate 
or complete)
+     * @param  parameters   the query parameters of the URL, without the 
signature
+     * @return              the signature, Base64 URL-safe encoded without 
padding
+     */
+    String sign(String callbackPath, Map<String, String> parameters) {
+        try {
+            Mac mac = Mac.getInstance(ALGORITHM);
+            mac.init(key);
+            byte[] digest = mac.doFinal(canonicalForm(callbackPath, 
parameters).getBytes(StandardCharsets.UTF_8));
+            return 
Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
+        } catch (GeneralSecurityException e) {
+            throw new IllegalStateException("Cannot compute the signature of 
the LRA participant callback URL", e);
+        }
+    }
+
+    /**
+     * Checks the signature of a callback URL, in constant time.
+     *
+     * @param  callbackPath the participant path that was called (compensate 
or complete)
+     * @param  parameters   the query parameters of the called URL, without 
the signature, may be <tt>null</tt> when
+     *                      they could not be read
+     * @param  signature    the signature received with the URL, may be 
<tt>null</tt>
+     * @return              true if the signature matches the path and the 
parameters
+     */
+    boolean verify(String callbackPath, Map<String, String> parameters, String 
signature) {
+        if (parameters == null || signature == null) {
+            return false;
+        }
+        byte[] expected = sign(callbackPath, 
parameters).getBytes(StandardCharsets.UTF_8);
+        return MessageDigest.isEqual(expected, 
signature.getBytes(StandardCharsets.UTF_8));
+    }
+
+    private static String canonicalForm(String callbackPath, Map<String, 
String> parameters) {
+        StringJoiner joiner = new StringJoiner("&", callbackPath + "?", "");
+        new TreeMap<>(parameters).forEach((name, value) -> 
joiner.add(encode(name) + "=" + encode(value)));
+        return joiner.toString();
+    }
+
+    private static String encode(String value) {
+        // a null option value is sent as an empty parameter value, so it is 
signed as one
+        return URLEncoder.encode(value != null ? value : "", 
StandardCharsets.UTF_8);
+    }
+}
diff --git 
a/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRAClient.java
 
b/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRAClient.java
index 0d9747c14075..5a2eac625b70 100644
--- 
a/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRAClient.java
+++ 
b/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRAClient.java
@@ -24,7 +24,9 @@ import java.net.URL;
 import java.net.http.HttpClient;
 import java.net.http.HttpRequest;
 import java.net.http.HttpResponse;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.CompletableFuture;
 import java.util.function.Function;
 
@@ -41,6 +43,10 @@ import static 
org.apache.camel.service.lra.LRAConstants.HEADER_LINK;
 import static org.apache.camel.service.lra.LRAConstants.HEADER_TIME_LIMIT;
 import static 
org.apache.camel.service.lra.LRAConstants.PARTICIPANT_PATH_COMPENSATE;
 import static 
org.apache.camel.service.lra.LRAConstants.PARTICIPANT_PATH_COMPLETE;
+import static org.apache.camel.service.lra.LRAConstants.URL_COMPENSATION_KEY;
+import static org.apache.camel.service.lra.LRAConstants.URL_COMPLETION_KEY;
+import static org.apache.camel.service.lra.LRAConstants.URL_LRA_KEY;
+import static org.apache.camel.service.lra.LRAConstants.URL_SIGNATURE_KEY;
 
 public class LRAClient implements Closeable {
 
@@ -115,8 +121,23 @@ public class LRAClient implements Closeable {
                     .compensation(step.getCompensation())
                     .completion(step.getCompletion());
 
-            String compensationURL = 
participantBaseUrl.path(PARTICIPANT_PATH_COMPENSATE).build();
-            String completionURL = 
participantBaseUrl.path(PARTICIPANT_PATH_COMPLETE).build();
+            LRACallbackSigner signer = sagaService.getCallbackSigner();
+            if (signer != null) {
+                // the LRA is signed too, so that a callback URL is only valid 
for the LRA it was enlisted for
+                participantBaseUrl = participantBaseUrl.query(URL_LRA_KEY, 
lra.toString());
+            }
+            LRAUrlBuilder compensationUrl = 
participantBaseUrl.path(PARTICIPANT_PATH_COMPENSATE);
+            LRAUrlBuilder completionUrl = 
participantBaseUrl.path(PARTICIPANT_PATH_COMPLETE);
+            if (signer != null) {
+                Map<String, String> parameters = callbackParameters(step, lra);
+                compensationUrl = compensationUrl.query(URL_SIGNATURE_KEY,
+                        signer.sign(PARTICIPANT_PATH_COMPENSATE, parameters));
+                completionUrl = completionUrl.query(URL_SIGNATURE_KEY,
+                        signer.sign(PARTICIPANT_PATH_COMPLETE, parameters));
+            }
+
+            String compensationURL = compensationUrl.build();
+            String completionURL = completionUrl.build();
 
             StringBuilder link = new StringBuilder();
             link.append('<').append(compensationURL).append('>').append("; 
rel=compensate");
@@ -184,6 +205,17 @@ public class LRAClient implements Closeable {
         return HttpRequest.newBuilder().uri(uri);
     }
 
+    /**
+     * The query parameters of the signed participant callback URLs of a step, 
as the participant receives them back.
+     */
+    private static Map<String, String> callbackParameters(LRASagaStep step, 
URL lra) {
+        Map<String, String> parameters = new HashMap<>(step.getOptions());
+        step.getCompensation().ifPresent(endpoint -> 
parameters.put(URL_COMPENSATION_KEY, endpoint.getEndpointUri()));
+        step.getCompletion().ifPresent(endpoint -> 
parameters.put(URL_COMPLETION_KEY, endpoint.getEndpointUri()));
+        parameters.put(URL_LRA_KEY, lra.toString());
+        return parameters;
+    }
+
     private URL toURL(Object url) {
         if (url == null) {
             return null;
diff --git 
a/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRAConstants.java
 
b/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRAConstants.java
index 1c3feead0ec9..69ddc4ab67a7 100644
--- 
a/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRAConstants.java
+++ 
b/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRAConstants.java
@@ -33,6 +33,8 @@ public final class LRAConstants {
 
     static final String URL_COMPENSATION_KEY = "Camel-Saga-Compensate";
     static final String URL_COMPLETION_KEY = "Camel-Saga-Complete";
+    static final String URL_SIGNATURE_KEY = "Camel-Saga-Signature";
+    static final String URL_LRA_KEY = "Camel-Saga-Lra";
 
     private LRAConstants() {
     }
diff --git 
a/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRASagaRoutes.java
 
b/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRASagaRoutes.java
index 4d442cff869c..048f786a5ba7 100644
--- 
a/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRASagaRoutes.java
+++ 
b/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRASagaRoutes.java
@@ -22,6 +22,7 @@ import java.util.*;
 import java.util.stream.Collectors;
 
 import org.apache.camel.Exchange;
+import org.apache.camel.Message;
 import org.apache.camel.builder.RouteBuilder;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -44,7 +45,7 @@ public class LRASagaRoutes extends RouteBuilder {
         rest(sagaService.getLocalParticipantContextPath())
                 
.put(PARTICIPANT_PATH_COMPENSATE).to("direct:lra-compensation");
         from("direct:lra-compensation").routeId("lra-compensation")
-                .process(this::verifyRequest)
+                .process(this::verifyCompensationRequest)
                 .choice()
                 .when(header(URL_COMPENSATION_KEY).isNotNull())
                 .toD("${header." + URL_COMPENSATION_KEY + "}")
@@ -53,7 +54,7 @@ public class LRASagaRoutes extends RouteBuilder {
         rest(sagaService.getLocalParticipantContextPath())
                 .put(PARTICIPANT_PATH_COMPLETE).to("direct:lra-completion");
         from("direct:lra-completion").routeId("lra-completion")
-                .process(this::verifyRequest)
+                .process(this::verifyCompletionRequest)
                 .choice()
                 .when(header(URL_COMPLETION_KEY).isNotNull())
                 .toD("${header." + URL_COMPLETION_KEY + "}")
@@ -69,8 +70,8 @@ public class LRASagaRoutes extends RouteBuilder {
             // first, split by parameter separator '&'
             // then collect the map with the variable name '[0]' and value 
'[1]', both url decoded
             result = Arrays.stream(queryStr.split("&")).collect(
-                    Collectors.toMap(element -> 
decode(saveArrayAccess(element.split("="), 0)),
-                            element -> 
decode(saveArrayAccess(element.split("="), 1))));
+                    Collectors.toMap(element -> 
decode(saveArrayAccess(element.split("=", 2), 0)),
+                            element -> 
decode(saveArrayAccess(element.split("=", 2), 1))));
 
         } else {
             LOG.debug("query param is empty, nothing to parse.");
@@ -93,14 +94,34 @@ public class LRASagaRoutes extends RouteBuilder {
         return URLDecoder.decode(encodedString, StandardCharsets.UTF_8);
     }
 
+    private void verifyCompensationRequest(Exchange exchange) {
+        verifyRequest(exchange, true);
+    }
+
+    private void verifyCompletionRequest(Exchange exchange) {
+        verifyRequest(exchange, false);
+    }
+
     /**
-     * Check if the request is pointing to an allowed URI to prevent 
unauthorized remote uri invocation
+     * Check if the request is pointing to an allowed URI to prevent 
unauthorized remote uri invocation. When a callback
+     * secret is configured, the signature of the called URL is verified 
first, and the step URIs are only taken from
+     * the signed parameters.
      */
-    private void verifyRequest(Exchange exchange) {
+    private void verifyRequest(Exchange exchange, boolean isCompensation) {
         if (exchange.getIn().getHeader(Exchange.SAGA_LONG_RUNNING_ACTION) == 
null) {
             throw new IllegalArgumentException("Missing " + 
Exchange.SAGA_LONG_RUNNING_ACTION + " header in received request");
         }
 
+        LRACallbackSigner signer = sagaService.getCallbackSigner();
+        if (signer != null) {
+            // the signed parameters are the only source of the step URIs: no 
fallback to the query below
+            if (applySignedParameters(exchange, signer, isCompensation)) {
+                checkAllowed(exchange.getIn().getHeader(URL_COMPENSATION_KEY, 
String.class));
+                checkAllowed(exchange.getIn().getHeader(URL_COMPLETION_KEY, 
String.class));
+            }
+            return;
+        }
+
         Set<String> usedURIs = new HashSet<>();
         String compensationURI = 
exchange.getIn().getHeader(URL_COMPENSATION_KEY, String.class);
         if (compensationURI != null) {
@@ -139,4 +160,63 @@ public class LRASagaRoutes extends RouteBuilder {
         }
     }
 
+    private void checkAllowed(String uri) {
+        if (uri != null && !sagaService.getRegisteredURIs().contains(uri)) {
+            throw new IllegalArgumentException("URI " + uri + " is not 
allowed");
+        }
+    }
+
+    /**
+     * Verifies the signature of the called URL, and sets the step URIs and 
the saga options from its query parameters,
+     * so that only signed values reach the step endpoint. A callback with a 
missing or invalid signature is answered
+     * with HTTP 403 and not routed any further.
+     *
+     * @return true if the signature is valid
+     */
+    private boolean applySignedParameters(Exchange exchange, LRACallbackSigner 
signer, boolean isCompensation) {
+        Message message = exchange.getIn();
+        String callbackPath = isCompensation ? PARTICIPANT_PATH_COMPENSATE : 
PARTICIPANT_PATH_COMPLETE;
+
+        // the raw query first, as some consumers, such as camel-netty-http, 
decode CamelHttpQuery
+        String query = message.getHeader(Exchange.HTTP_RAW_QUERY, 
String.class);
+        if (query == null) {
+            query = message.getHeader(Exchange.HTTP_QUERY, String.class);
+        }
+        Map<String, String> parameters;
+        try {
+            parameters = parseQuery(query);
+        } catch (IllegalStateException e) {
+            // a repeated parameter, which a URL enlisted by the participant 
never has
+            parameters = null;
+        }
+        String signature = parameters != null ? 
parameters.remove(URL_SIGNATURE_KEY) : null;
+
+        message.removeHeader(URL_COMPENSATION_KEY);
+        message.removeHeader(URL_COMPLETION_KEY);
+        message.removeHeader(URL_SIGNATURE_KEY);
+        if (!signer.verify(callbackPath, parameters, signature) || 
parameters.get(URL_LRA_KEY) == null) {
+            LOG.warn("Rejected a call to the LRA participant endpoint {} 
without a valid signature", callbackPath);
+            message.setHeader(Exchange.HTTP_RESPONSE_CODE, 403);
+            message.setBody(null);
+            exchange.setRouteStop(true);
+            return false;
+        }
+        // the step works on the LRA the URL was signed for, not on the one 
named by the request
+        message.setHeader(Exchange.SAGA_LONG_RUNNING_ACTION, 
parameters.remove(URL_LRA_KEY));
+        parameters.forEach(message::setHeader);
+        // keep the signature out of the step route
+        removeSignature(message, Exchange.HTTP_RAW_QUERY);
+        removeSignature(message, Exchange.HTTP_QUERY);
+        return true;
+    }
+
+    private static void removeSignature(Message message, String queryHeader) {
+        String query = message.getHeader(queryHeader, String.class);
+        if (query != null) {
+            message.setHeader(queryHeader, Arrays.stream(query.split("&"))
+                    .filter(parameter -> 
!parameter.startsWith(URL_SIGNATURE_KEY + "="))
+                    .collect(Collectors.joining("&")));
+        }
+    }
+
 }
diff --git 
a/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRASagaService.java
 
b/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRASagaService.java
index 6a6359d22fca..3c67e2691d0d 100644
--- 
a/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRASagaService.java
+++ 
b/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRASagaService.java
@@ -39,6 +39,8 @@ import org.apache.camel.spi.Metadata;
 import org.apache.camel.spi.annotations.JdkService;
 import org.apache.camel.support.service.ServiceSupport;
 import org.apache.camel.util.ObjectHelper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * A Camel saga service based on Microprofile LRA 
(https://github.com/eclipse/microprofile-lra).
@@ -48,6 +50,11 @@ import org.apache.camel.util.ObjectHelper;
 @ManagedResource(description = "Managed LRASagaService")
 public class LRASagaService extends ServiceSupport implements StaticService, 
CamelSagaService {
 
+    private static final Logger LOG = 
LoggerFactory.getLogger(LRASagaService.class);
+    private static final List<String> RESERVED_OPTION_NAMES = List.of(
+            LRAConstants.URL_COMPENSATION_KEY, 
LRAConstants.URL_COMPLETION_KEY, LRAConstants.URL_SIGNATURE_KEY,
+            LRAConstants.URL_LRA_KEY);
+
     private CamelContext camelContext;
     private ScheduledExecutorService executorService;
     private LRAClient client;
@@ -65,6 +72,9 @@ public class LRASagaService extends ServiceSupport implements 
StaticService, Cam
     private String localParticipantContextPath = 
LRAConstants.DEFAULT_LOCAL_PARTICIPANT_CONTEXT_PATH;
     @Metadata(label = "security")
     private String allowedCoordinatorUrls;
+    @Metadata(label = "security", secret = true)
+    private String callbackSecret;
+    private LRACallbackSigner callbackSigner;
 
     public LRASagaService() {
     }
@@ -187,11 +197,31 @@ public class LRASagaService extends ServiceSupport 
implements StaticService, Cam
 
     @Override
     public void registerStep(CamelSagaStep step) {
+        checkOptionNames(step);
         // Register which uris should be exposed
         
step.getCompensation().map(Endpoint::getEndpointUri).map(this.sagaURIs::add);
         
step.getCompletion().map(Endpoint::getEndpointUri).map(this.sagaURIs::add);
     }
 
+    /**
+     * The saga options travel in the query of the participant callback URLs, 
next to the parameters of the saga
+     * service, so an option cannot use one of their names. Header names are 
case-insensitive, so neither can it use
+     * them in another case.
+     */
+    private static void checkOptionNames(CamelSagaStep step) {
+        if (step.getOptions() == null) {
+            return;
+        }
+        for (String name : step.getOptions().keySet()) {
+            for (String reserved : RESERVED_OPTION_NAMES) {
+                if (reserved.equalsIgnoreCase(name)) {
+                    throw new IllegalArgumentException(
+                            "The saga option " + name + " uses a name reserved 
by the LRA saga service");
+                }
+            }
+        }
+    }
+
     @Override
     protected void doStart() throws Exception {
         for (URI coordinator : additionalCoordinators()) {
@@ -203,6 +233,16 @@ public class LRASagaService extends ServiceSupport 
implements StaticService, Cam
                                                    + " URL could be read from: 
" + coordinator);
             }
         }
+        if (callbackSecret != null && callbackSecret.isBlank()) {
+            // for example an empty environment variable, which would 
otherwise switch the verification off
+            throw new IllegalArgumentException(
+                    "callbackSecret is set on the LRA saga service but blank: 
set a secret, or remove the option");
+        }
+        if (callbackSigner == null) {
+            LOG.warn("Callbacks to the LRA participant endpoints under {} are 
not signed. Configure a callbackSecret"
+                     + " on the LRA saga service, or make sure that only the 
LRA coordinator can reach these endpoints.",
+                    localParticipantContextPath);
+        }
         if (this.executorService == null) {
             this.executorService = camelContext.getExecutorServiceManager()
                     .newDefaultScheduledThreadPool(this, "saga-lra");
@@ -310,6 +350,24 @@ public class LRASagaService extends ServiceSupport 
implements StaticService, Cam
         this.allowedCoordinatorUrls = allowedCoordinatorUrls;
     }
 
+    public String getCallbackSecret() {
+        return callbackSecret;
+    }
+
+    /**
+     * Secret used to sign the participant callback URLs that are enlisted 
with the LRA coordinator, and to verify them
+     * when the coordinator calls back. When set, a compensation or completion 
callback with a missing or invalid
+     * signature is rejected with HTTP 403. All the instances of a participant 
must use the same secret.
+     */
+    public void setCallbackSecret(String callbackSecret) {
+        this.callbackSecret = callbackSecret;
+        this.callbackSigner = ObjectHelper.isNotEmpty(callbackSecret) ? new 
LRACallbackSigner(callbackSecret) : null;
+    }
+
+    LRACallbackSigner getCallbackSigner() {
+        return callbackSigner;
+    }
+
     public Set<String> getRegisteredURIs() {
         return sagaURIs;
     }
diff --git 
a/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRAUrlBuilder.java
 
b/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRAUrlBuilder.java
index e3f4bf80c9c4..b222ec250356 100644
--- 
a/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRAUrlBuilder.java
+++ 
b/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRAUrlBuilder.java
@@ -16,6 +16,8 @@
  */
 package org.apache.camel.service.lra;
 
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
 import java.util.Map;
 import java.util.Optional;
 
@@ -93,7 +95,7 @@ public class LRAUrlBuilder {
         } else {
             copy.query += "&";
         }
-        copy.query += toNonnullString(key) + "=" + toNonnullString(value);
+        copy.query += encode(toNonnullString(key)) + "=" + 
encode(toNonnullString(value));
         return copy;
     }
 
@@ -116,6 +118,10 @@ public class LRAUrlBuilder {
         return first + "/" + second;
     }
 
+    private String encode(String value) {
+        return URLEncoder.encode(value, StandardCharsets.UTF_8);
+    }
+
     private String toNonnullString(Object obj) {
         return obj != null ? obj.toString() : "";
     }
diff --git 
a/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRACallbackSignerTest.java
 
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRACallbackSignerTest.java
new file mode 100644
index 000000000000..4b78e6bfb0d8
--- /dev/null
+++ 
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRACallbackSignerTest.java
@@ -0,0 +1,139 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.service.lra;
+
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import static 
org.apache.camel.service.lra.LRAConstants.PARTICIPANT_PATH_COMPENSATE;
+import static 
org.apache.camel.service.lra.LRAConstants.PARTICIPANT_PATH_COMPLETE;
+import static org.apache.camel.service.lra.LRAConstants.URL_COMPENSATION_KEY;
+import static org.apache.camel.service.lra.LRAConstants.URL_COMPLETION_KEY;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class LRACallbackSignerTest {
+
+    private final LRACallbackSigner signer = new 
LRACallbackSigner("a-callback-secret");
+
+    @Test
+    void signatureMatchesTheSignedParameters() {
+        String signature = signer.sign(PARTICIPANT_PATH_COMPENSATE, 
parameters());
+
+        assertTrue(signer.verify(PARTICIPANT_PATH_COMPENSATE, parameters(), 
signature));
+    }
+
+    @Test
+    void signatureDoesNotDependOnTheParameterOrder() {
+        Map<String, String> ordered = new LinkedHashMap<>();
+        ordered.put("id", "42");
+        ordered.put("name", "Nicola");
+        Map<String, String> reversed = new LinkedHashMap<>();
+        reversed.put("name", "Nicola");
+        reversed.put("id", "42");
+
+        assertEquals(signer.sign(PARTICIPANT_PATH_COMPENSATE, ordered), 
signer.sign(PARTICIPANT_PATH_COMPENSATE, reversed));
+    }
+
+    @Test
+    void changedParameterValueDoesNotMatch() {
+        String signature = signer.sign(PARTICIPANT_PATH_COMPENSATE, 
parameters());
+        Map<String, String> changed = parameters();
+        changed.put("id", "43");
+
+        assertFalse(signer.verify(PARTICIPANT_PATH_COMPENSATE, changed, 
signature));
+    }
+
+    @Test
+    void addedParameterDoesNotMatch() {
+        String signature = signer.sign(PARTICIPANT_PATH_COMPENSATE, 
parameters());
+        Map<String, String> added = parameters();
+        added.put("extra", "value");
+
+        assertFalse(signer.verify(PARTICIPANT_PATH_COMPENSATE, added, 
signature));
+    }
+
+    @Test
+    void removedParameterDoesNotMatch() {
+        String signature = signer.sign(PARTICIPANT_PATH_COMPENSATE, 
parameters());
+        Map<String, String> removed = parameters();
+        removed.remove("name");
+
+        assertFalse(signer.verify(PARTICIPANT_PATH_COMPENSATE, removed, 
signature));
+    }
+
+    @Test
+    void signatureIsBoundToTheCallbackPath() {
+        String signature = signer.sign(PARTICIPANT_PATH_COMPENSATE, 
parameters());
+
+        assertFalse(signer.verify(PARTICIPANT_PATH_COMPLETE, parameters(), 
signature));
+    }
+
+    @Test
+    void signatureIsBoundToTheSecret() {
+        String signature = new 
LRACallbackSigner("another-secret").sign(PARTICIPANT_PATH_COMPENSATE, 
parameters());
+
+        assertFalse(signer.verify(PARTICIPANT_PATH_COMPENSATE, parameters(), 
signature));
+    }
+
+    @Test
+    void missingSignatureDoesNotMatch() {
+        assertFalse(signer.verify(PARTICIPANT_PATH_COMPENSATE, parameters(), 
null));
+    }
+
+    @Test
+    void missingParametersDoNotMatch() {
+        String signature = signer.sign(PARTICIPANT_PATH_COMPENSATE, 
parameters());
+
+        assertFalse(signer.verify(PARTICIPANT_PATH_COMPENSATE, null, 
signature));
+    }
+
+    @Test
+    void nullValueIsSignedAsAnEmptyValue() {
+        Map<String, String> withNull = parameters();
+        withNull.put("note", null);
+        Map<String, String> withEmpty = parameters();
+        withEmpty.put("note", "");
+
+        assertEquals(signer.sign(PARTICIPANT_PATH_COMPENSATE, withNull), 
signer.sign(PARTICIPANT_PATH_COMPENSATE, withEmpty));
+    }
+
+    @Test
+    void separatorsInValuesDoNotChangeTheParameterBoundaries() {
+        Map<String, String> one = new HashMap<>();
+        one.put("a", "b&c=d");
+        Map<String, String> two = new HashMap<>();
+        two.put("a", "b");
+        two.put("c", "d");
+
+        assertNotEquals(signer.sign(PARTICIPANT_PATH_COMPENSATE, one), 
signer.sign(PARTICIPANT_PATH_COMPENSATE, two));
+    }
+
+    private static Map<String, String> parameters() {
+        Map<String, String> parameters = new HashMap<>();
+        parameters.put(URL_COMPENSATION_KEY, "direct://cancelOrder");
+        parameters.put(URL_COMPLETION_KEY, "direct://confirmOrder");
+        parameters.put("id", "42");
+        parameters.put("name", "Nicola");
+        return parameters;
+    }
+}
diff --git 
a/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRAClientTest.java
 
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRAClientTest.java
index e1cc66f8e93d..c8709286d2cf 100644
--- 
a/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRAClientTest.java
+++ 
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRAClientTest.java
@@ -18,16 +18,37 @@ package org.apache.camel.service.lra;
 
 import java.net.MalformedURLException;
 import java.net.URI;
+import java.net.URLDecoder;
 import java.net.http.HttpClient;
 import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 import org.apache.camel.Exchange;
+import org.apache.camel.Expression;
+import org.apache.camel.saga.CamelSagaStep;
+import org.apache.camel.support.DefaultExchange;
+import org.apache.camel.support.builder.ExpressionBuilder;
 import org.apache.camel.test.junit5.CamelTestSupport;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
 import org.mockito.Mockito;
 
+import static 
org.apache.camel.service.lra.LRAConstants.PARTICIPANT_PATH_COMPENSATE;
+import static 
org.apache.camel.service.lra.LRAConstants.PARTICIPANT_PATH_COMPLETE;
+import static org.apache.camel.service.lra.LRAConstants.URL_LRA_KEY;
+import static org.apache.camel.service.lra.LRAConstants.URL_SIGNATURE_KEY;
+
 public class LRAClientTest extends CamelTestSupport {
 
     public LRAClientTest() {
@@ -118,6 +139,93 @@ public class LRAClientTest extends CamelTestSupport {
         Assertions.assertEquals(actual.build(), expected.build());
     }
 
+    @DisplayName("Tests whether join() signs the participant callback URLs 
when a callback secret is set")
+    @Test
+    void testJoinSignsCallbackUrlsWithCallbackSecret() throws Exception {
+        Map<String, Map<String, String>> callbacks = 
joinAndCaptureCallbacks("a-callback-secret");
+
+        LRACallbackSigner signer = new LRACallbackSigner("a-callback-secret");
+        for (String path : new String[] { PARTICIPANT_PATH_COMPENSATE, 
PARTICIPANT_PATH_COMPLETE }) {
+            Map<String, String> parameters = callbacks.get(path);
+            Assertions.assertEquals("42", parameters.get("id"), "saga option 
must be in the " + path + " URL");
+            Assertions.assertEquals("http://localhost/lra-coordinator/0_1";, 
parameters.get(URL_LRA_KEY),
+                    "the LRA must be in the " + path + " URL");
+            String signature = parameters.remove(URL_SIGNATURE_KEY);
+            Assertions.assertTrue(signer.verify(path, parameters, signature),
+                    "the " + path + " URL must carry a valid signature");
+        }
+    }
+
+    @DisplayName("Tests whether join() leaves the participant callback URLs 
unsigned without a callback secret")
+    @Test
+    void testJoinDoesNotSignCallbackUrlsWithoutCallbackSecret() throws 
Exception {
+        Map<String, Map<String, String>> callbacks = 
joinAndCaptureCallbacks(null);
+
+        for (Map<String, String> parameters : callbacks.values()) {
+            Assertions.assertEquals("42", parameters.get("id"), "saga option 
must be in the callback URL");
+            Assertions.assertFalse(parameters.containsKey(URL_SIGNATURE_KEY), 
"callback URL must not be signed");
+            Assertions.assertFalse(parameters.containsKey(URL_LRA_KEY), 
"callback URL must not carry the LRA");
+        }
+    }
+
+    /**
+     * Joins an LRA through a mocked HTTP client, and returns the query 
parameters of the enlisted callback URLs, by
+     * participant path.
+     */
+    private Map<String, Map<String, String>> joinAndCaptureCallbacks(String 
callbackSecret) throws Exception {
+        ScheduledExecutorService executor = 
Executors.newSingleThreadScheduledExecutor();
+        try {
+            LRASagaService sagaService = new LRASagaService() {
+                @Override
+                public ScheduledExecutorService getExecutorService() {
+                    return executor;
+                }
+            };
+            applyMockProperties(sagaService);
+            sagaService.setCallbackSecret(callbackSecret);
+
+            HttpClient httpClient = Mockito.mock(HttpClient.class);
+            @SuppressWarnings("unchecked")
+            HttpResponse<String> response = Mockito.mock(HttpResponse.class);
+            Mockito.when(response.statusCode()).thenReturn(200);
+            ArgumentCaptor<HttpRequest> request = 
ArgumentCaptor.forClass(HttpRequest.class);
+            Mockito.when(httpClient.sendAsync(request.capture(), 
Mockito.<HttpResponse.BodyHandler<String>> any()))
+                    .thenReturn(CompletableFuture.completedFuture(response));
+
+            Map<String, Expression> options = new HashMap<>();
+            options.put("id", ExpressionBuilder.constantExpression("42"));
+            CamelSagaStep step = new CamelSagaStep(
+                    context().getEndpoint("direct:cancelOrder"), 
context().getEndpoint("direct:confirmOrder"), options, null);
+            Exchange exchange = new DefaultExchange(context());
+
+            new LRAClient(sagaService, httpClient)
+                    
.join(URI.create("http://localhost/lra-coordinator/0_1";).toURL(),
+                            LRASagaStep.fromCamelSagaStep(step, exchange), 
exchange)
+                    .get(10, TimeUnit.SECONDS);
+
+            String link = 
request.getValue().headers().firstValue("Link").orElseThrow();
+            Map<String, Map<String, String>> callbacks = new HashMap<>();
+            Matcher matcher = Pattern.compile("<[^>?]+(/[a-z]+)\\?([^>]*)>; 
rel=").matcher(link);
+            while (matcher.find()) {
+                callbacks.put(matcher.group(1), parseQuery(matcher.group(2)));
+            }
+            Assertions.assertEquals(2, callbacks.size(), "the Link header must 
hold the compensate and complete URLs");
+            return callbacks;
+        } finally {
+            executor.shutdownNow();
+        }
+    }
+
+    private static Map<String, String> parseQuery(String query) {
+        Map<String, String> parameters = new HashMap<>();
+        for (String parameter : query.split("&")) {
+            String[] pair = parameter.split("=", 2);
+            parameters.put(URLDecoder.decode(pair[0], StandardCharsets.UTF_8),
+                    URLDecoder.decode(pair[1], StandardCharsets.UTF_8));
+        }
+        return parameters;
+    }
+
     private void applyMockProperties(LRASagaService sagaService) {
         sagaService.setCoordinatorUrl("mockCoordinatorUrl");
         sagaService.setLocalParticipantUrl("mockLocalParticipantUrl");
diff --git 
a/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaRoutesCallbackSignatureTest.java
 
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaRoutesCallbackSignatureTest.java
new file mode 100644
index 000000000000..eeae18563b86
--- /dev/null
+++ 
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaRoutesCallbackSignatureTest.java
@@ -0,0 +1,301 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.service.lra;
+
+import java.lang.reflect.Method;
+import java.net.URI;
+import java.net.URLDecoder;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.StringJoiner;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import org.apache.camel.Message;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.model.SagaPropagation;
+import org.apache.camel.support.DefaultExchange;
+import org.apache.camel.test.AvailablePortFinder;
+import org.apache.camel.test.junit5.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import static 
org.apache.camel.service.lra.LRAConstants.DEFAULT_LOCAL_PARTICIPANT_CONTEXT_PATH;
+import static 
org.apache.camel.service.lra.LRAConstants.PARTICIPANT_PATH_COMPENSATE;
+import static 
org.apache.camel.service.lra.LRAConstants.PARTICIPANT_PATH_COMPLETE;
+import static org.apache.camel.service.lra.LRAConstants.URL_COMPENSATION_KEY;
+import static org.apache.camel.service.lra.LRAConstants.URL_COMPLETION_KEY;
+import static org.apache.camel.service.lra.LRAConstants.URL_LRA_KEY;
+import static org.apache.camel.service.lra.LRAConstants.URL_SIGNATURE_KEY;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * Tests the participant callback routes when a callback secret is configured 
on the LRA saga service.
+ */
+class LRASagaRoutesCallbackSignatureTest extends CamelTestSupport {
+
+    private static final String CALLBACK_SECRET = "a-callback-secret";
+    // the LRA a callback URL is signed for, and another one named by the 
requests
+    private static final String LRA = "http://localhost/lra-coordinator/0_1";;
+    private static final String REQUEST_LRA = 
"http://localhost/lra-coordinator/0_2";;
+
+    @RegisterExtension
+    AvailablePortFinder.Port port = AvailablePortFinder.find();
+
+    private final LRACallbackSigner signer = new 
LRACallbackSigner(CALLBACK_SECRET);
+    private final LRASagaService sagaService = new LRASagaService();
+    private final HttpClient client = HttpClient.newHttpClient();
+
+    @Override
+    protected CamelContext createCamelContext() throws Exception {
+        CamelContext context = super.createCamelContext();
+
+        // the tests never start a saga, so the coordinator is not called
+        sagaService.setCoordinatorUrl("http://localhost:1";);
+        sagaService.setLocalParticipantUrl("http://localhost:"; + 
port.getPort());
+        sagaService.setCallbackSecret(CALLBACK_SECRET);
+        context.addService(sagaService);
+
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                restConfiguration().port(port.getPort());
+            }
+        });
+        return context;
+    }
+
+    @Override
+    protected RoutesBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                // registers the compensation and completion endpoints of the 
saga step
+                from("direct:order")
+                        .saga().propagation(SagaPropagation.SUPPORTS)
+                        .option("orderId", header("orderId"))
+                        .compensation("mock:cancel")
+                        .completion("mock:confirm")
+                        .to("mock:order");
+            }
+        };
+    }
+
+    @Test
+    void signedCompensationIsRouted() throws Exception {
+        MockEndpoint cancel = getMockEndpoint("mock:cancel");
+        cancel.expectedMessageCount(1);
+        cancel.expectedHeaderReceived("orderId", "42");
+        cancel.expectedHeaderReceived(Exchange.SAGA_LONG_RUNNING_ACTION, LRA);
+
+        assertEquals(200,
+                callback(PARTICIPANT_PATH_COMPENSATE, 
signedQuery(PARTICIPANT_PATH_COMPENSATE, parameters("42"))));
+
+        cancel.assertIsSatisfied();
+    }
+
+    @Test
+    void signedCompletionIsRouted() throws Exception {
+        MockEndpoint confirm = getMockEndpoint("mock:confirm");
+        confirm.expectedMessageCount(1);
+        confirm.expectedHeaderReceived("orderId", "42");
+        confirm.expectedHeaderReceived(Exchange.SAGA_LONG_RUNNING_ACTION, LRA);
+
+        assertEquals(200,
+                callback(PARTICIPANT_PATH_COMPLETE, 
signedQuery(PARTICIPANT_PATH_COMPLETE, parameters("42"))));
+
+        confirm.assertIsSatisfied();
+    }
+
+    @Test
+    void callbackWithoutSignatureIsRejected() throws Exception {
+        MockEndpoint cancel = getMockEndpoint("mock:cancel");
+        cancel.expectedMessageCount(0);
+
+        assertEquals(403, callback(PARTICIPANT_PATH_COMPENSATE, 
query(parameters("42"))));
+
+        cancel.assertIsSatisfied();
+    }
+
+    @Test
+    void callbackWithChangedOptionIsRejected() throws Exception {
+        MockEndpoint cancel = getMockEndpoint("mock:cancel");
+        cancel.expectedMessageCount(0);
+        String signed = signedQuery(PARTICIPANT_PATH_COMPENSATE, 
parameters("42"));
+
+        assertEquals(403, callback(PARTICIPANT_PATH_COMPENSATE, 
signed.replace("orderId=42", "orderId=43")));
+
+        cancel.assertIsSatisfied();
+    }
+
+    @Test
+    void compensationSignatureIsRejectedForCompletion() throws Exception {
+        MockEndpoint confirm = getMockEndpoint("mock:confirm");
+        confirm.expectedMessageCount(0);
+
+        assertEquals(403,
+                callback(PARTICIPANT_PATH_COMPLETE, 
signedQuery(PARTICIPANT_PATH_COMPENSATE, parameters("42"))));
+
+        confirm.assertIsSatisfied();
+    }
+
+    @Test
+    void signedValuesTakePrecedenceOverRequestHeaders() throws Exception {
+        MockEndpoint cancel = getMockEndpoint("mock:cancel");
+        cancel.expectedMessageCount(1);
+        cancel.expectedHeaderReceived("orderId", "42");
+
+        assertEquals(200, callback(PARTICIPANT_PATH_COMPENSATE,
+                signedQuery(PARTICIPANT_PATH_COMPENSATE, parameters("42")), 
"orderId", "43"));
+
+        cancel.assertIsSatisfied();
+    }
+
+    @Test
+    void signedCallbackToUnregisteredEndpointIsRejected() throws Exception {
+        MockEndpoint other = getMockEndpoint("mock:other");
+        other.expectedMessageCount(0);
+        Map<String, String> parameters = parameters("42");
+        parameters.put(URL_COMPENSATION_KEY, "mock://other");
+
+        assertEquals(500, callback(PARTICIPANT_PATH_COMPENSATE, 
signedQuery(PARTICIPANT_PATH_COMPENSATE, parameters)));
+
+        other.assertIsSatisfied();
+    }
+
+    @Test
+    void callbackForAnotherLraIsRejected() throws Exception {
+        MockEndpoint cancel = getMockEndpoint("mock:cancel");
+        cancel.expectedMessageCount(0);
+        String signed = signedQuery(PARTICIPANT_PATH_COMPENSATE, 
parameters("42"));
+
+        assertEquals(403, callback(PARTICIPANT_PATH_COMPENSATE, 
signed.replace(encode(LRA), encode(REQUEST_LRA))));
+
+        cancel.assertIsSatisfied();
+    }
+
+    @Test
+    void signedCallbackWithoutLraIsRejected() throws Exception {
+        MockEndpoint cancel = getMockEndpoint("mock:cancel");
+        cancel.expectedMessageCount(0);
+        Map<String, String> parameters = parameters("42");
+        parameters.remove(URL_LRA_KEY);
+
+        assertEquals(403, callback(PARTICIPANT_PATH_COMPENSATE, 
signedQuery(PARTICIPANT_PATH_COMPENSATE, parameters)));
+
+        cancel.assertIsSatisfied();
+    }
+
+    @Test
+    void signatureIsKeptOutOfTheStepRoute() throws Exception {
+        MockEndpoint cancel = getMockEndpoint("mock:cancel");
+        cancel.expectedMessageCount(1);
+
+        assertEquals(200,
+                callback(PARTICIPANT_PATH_COMPENSATE, 
signedQuery(PARTICIPANT_PATH_COMPENSATE, parameters("42"))));
+
+        cancel.assertIsSatisfied();
+        Message received = cancel.getReceivedExchanges().get(0).getIn();
+        for (String header : new String[] { Exchange.HTTP_QUERY, 
Exchange.HTTP_RAW_QUERY }) {
+            assertFalse(received.getHeader(header, "", 
String.class).contains(URL_SIGNATURE_KEY), header);
+        }
+    }
+
+    @Test
+    void rawQueryIsReadBeforeTheDecodedOne() throws Exception {
+        // camel-netty-http sets CamelHttpQuery to the decoded query, and 
CamelHttpRawQuery to the query as sent;
+        // the rest-dsl route inlines the verification, so it is called 
directly, as LRASagaRoutesTest does
+        String rawQuery = signedQuery(PARTICIPANT_PATH_COMPENSATE, 
parameters("4&2=x+y%"));
+        Exchange exchange = new DefaultExchange(context);
+        exchange.getIn().setHeader(Exchange.SAGA_LONG_RUNNING_ACTION, 
REQUEST_LRA);
+        exchange.getIn().setHeader(Exchange.HTTP_RAW_QUERY, rawQuery);
+        exchange.getIn().setHeader(Exchange.HTTP_QUERY, 
URLDecoder.decode(rawQuery, StandardCharsets.UTF_8));
+
+        Method verify = 
LRASagaRoutes.class.getDeclaredMethod("verifyCompensationRequest", 
Exchange.class);
+        verify.setAccessible(true);
+        verify.invoke(new LRASagaRoutes(sagaService), exchange);
+
+        assertNull(exchange.getIn().getHeader(Exchange.HTTP_RESPONSE_CODE));
+        assertEquals("4&2=x+y%", exchange.getIn().getHeader("orderId"));
+        assertEquals(LRA, 
exchange.getIn().getHeader(Exchange.SAGA_LONG_RUNNING_ACTION));
+    }
+
+    @Test
+    void signedCallbackWithoutStepUrisDoesNotReadThemFromTheQuery() throws 
Exception {
+        // a step without compensation and completion URIs, whose signed 
option value holds an encoded parameter that
+        // the decoded CamelHttpQuery of camel-netty-http shows as a step URI
+        Map<String, String> parameters = new LinkedHashMap<>();
+        parameters.put("note", "x&" + URL_COMPENSATION_KEY + "=mock://cancel");
+        parameters.put(URL_LRA_KEY, LRA);
+        String rawQuery = signedQuery(PARTICIPANT_PATH_COMPENSATE, parameters);
+        Exchange exchange = new DefaultExchange(context);
+        exchange.getIn().setHeader(Exchange.SAGA_LONG_RUNNING_ACTION, 
REQUEST_LRA);
+        exchange.getIn().setHeader(Exchange.HTTP_RAW_QUERY, rawQuery);
+        exchange.getIn().setHeader(Exchange.HTTP_QUERY, 
URLDecoder.decode(rawQuery, StandardCharsets.UTF_8));
+
+        Method verify = 
LRASagaRoutes.class.getDeclaredMethod("verifyCompensationRequest", 
Exchange.class);
+        verify.setAccessible(true);
+        verify.invoke(new LRASagaRoutes(sagaService), exchange);
+
+        assertNull(exchange.getIn().getHeader(Exchange.HTTP_RESPONSE_CODE));
+        assertEquals("x&" + URL_COMPENSATION_KEY + "=mock://cancel", 
exchange.getIn().getHeader("note"));
+        assertNull(exchange.getIn().getHeader(URL_COMPENSATION_KEY));
+    }
+
+    private int callback(String path, String query, String... headers) throws 
Exception {
+        HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(
+                "http://localhost:"; + port.getPort() + 
DEFAULT_LOCAL_PARTICIPANT_CONTEXT_PATH + path + "?" + query))
+                .header(Exchange.SAGA_LONG_RUNNING_ACTION, REQUEST_LRA)
+                .PUT(HttpRequest.BodyPublishers.noBody());
+        if (headers.length > 0) {
+            request.headers(headers);
+        }
+        return client.send(request.build(), 
HttpResponse.BodyHandlers.discarding()).statusCode();
+    }
+
+    private String signedQuery(String path, Map<String, String> parameters) {
+        return query(parameters) + "&" + URL_SIGNATURE_KEY + "=" + 
signer.sign(path, parameters);
+    }
+
+    private static String query(Map<String, String> parameters) {
+        StringJoiner query = new StringJoiner("&");
+        parameters.forEach((name, value) -> query.add(encode(name) + "=" + 
encode(value)));
+        return query.toString();
+    }
+
+    private static String encode(String value) {
+        return URLEncoder.encode(value, StandardCharsets.UTF_8);
+    }
+
+    private static Map<String, String> parameters(String orderId) {
+        Map<String, String> parameters = new LinkedHashMap<>();
+        parameters.put("orderId", orderId);
+        parameters.put(URL_COMPENSATION_KEY, "mock://cancel");
+        parameters.put(URL_COMPLETION_KEY, "mock://confirm");
+        parameters.put(URL_LRA_KEY, LRA);
+        return parameters;
+    }
+}
diff --git 
a/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaRoutesTest.java
 
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaRoutesTest.java
index c9762505cf6e..022bec7885d5 100644
--- 
a/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaRoutesTest.java
+++ 
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaRoutesTest.java
@@ -79,6 +79,36 @@ public class LRASagaRoutesTest {
                 "query parameter value for name 'key2' has unexpected 
content");
     }
 
+    @DisplayName("Tests parseQuery() preserves values containing '=' 
characters")
+    @Test
+    void testParseQueryWithEqualsInValue() throws InvocationTargetException, 
IllegalAccessException, NoSuchMethodException {
+
+        Map<String, String> testResult = (Map<String, String>) 
getParseQueryMethod()
+                .invoke(new LRASagaRoutes(null),
+                        "key1=value%3Dwith%3Dequals&key2=normal");
+
+        Assertions.assertEquals(2, testResult.size(), "query parameter count 
must be two");
+        Assertions.assertEquals("value=with=equals", testResult.get("key1"),
+                "value containing '=' characters must be preserved");
+        Assertions.assertEquals("normal", testResult.get("key2"));
+    }
+
+    @DisplayName("Tests parseQuery() decodes URL-encoded special characters 
correctly")
+    @Test
+    void testParseQueryWithEncodedSpecialChars()
+            throws InvocationTargetException, IllegalAccessException, 
NoSuchMethodException {
+
+        Map<String, String> testResult = (Map<String, String>) 
getParseQueryMethod()
+                .invoke(new LRASagaRoutes(null),
+                        "key1=value+with%26ampersand&key2=a%2Bb");
+
+        Assertions.assertEquals(2, testResult.size(), "query parameter count 
must be two");
+        Assertions.assertEquals("value with&ampersand", testResult.get("key1"),
+                "encoded '&' and '+' must be decoded correctly");
+        Assertions.assertEquals("a+b", testResult.get("key2"),
+                "encoded '+' must be decoded correctly");
+    }
+
     @DisplayName("Tests parseQuery() to handle incorrect query string (no 
value is given)")
     @Test
     void testParseQuerySuccessEncoded() throws InvocationTargetException, 
IllegalAccessException, NoSuchMethodException {
diff --git 
a/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaRoutesUnsignedCallbackTest.java
 
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaRoutesUnsignedCallbackTest.java
new file mode 100644
index 000000000000..f9a215280db4
--- /dev/null
+++ 
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaRoutesUnsignedCallbackTest.java
@@ -0,0 +1,136 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.service.lra;
+
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.model.SagaPropagation;
+import org.apache.camel.test.AvailablePortFinder;
+import org.apache.camel.test.junit5.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import static 
org.apache.camel.service.lra.LRAConstants.DEFAULT_LOCAL_PARTICIPANT_CONTEXT_PATH;
+import static 
org.apache.camel.service.lra.LRAConstants.PARTICIPANT_PATH_COMPENSATE;
+import static 
org.apache.camel.service.lra.LRAConstants.PARTICIPANT_PATH_COMPLETE;
+import static org.apache.camel.service.lra.LRAConstants.URL_COMPENSATION_KEY;
+import static org.apache.camel.service.lra.LRAConstants.URL_COMPLETION_KEY;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Tests the participant callback routes when no callback secret is configured 
on the LRA saga service: the callbacks
+ * are routed as before, without a signature.
+ */
+class LRASagaRoutesUnsignedCallbackTest extends CamelTestSupport {
+
+    @RegisterExtension
+    AvailablePortFinder.Port port = AvailablePortFinder.find();
+
+    private final HttpClient client = HttpClient.newHttpClient();
+
+    @Override
+    protected CamelContext createCamelContext() throws Exception {
+        CamelContext context = super.createCamelContext();
+
+        LRASagaService sagaService = new LRASagaService();
+        // the tests never start a saga, so the coordinator is not called
+        sagaService.setCoordinatorUrl("http://localhost:1";);
+        sagaService.setLocalParticipantUrl("http://localhost:"; + 
port.getPort());
+        context.addService(sagaService);
+
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                restConfiguration().port(port.getPort());
+            }
+        });
+        return context;
+    }
+
+    @Override
+    protected RoutesBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                // registers the compensation and completion endpoints of the 
saga step
+                from("direct:order")
+                        .saga().propagation(SagaPropagation.SUPPORTS)
+                        .option("orderId", header("orderId"))
+                        .compensation("mock:cancel")
+                        .completion("mock:confirm")
+                        .to("mock:order");
+            }
+        };
+    }
+
+    @Test
+    void compensationIsRouted() throws Exception {
+        MockEndpoint cancel = getMockEndpoint("mock:cancel");
+        cancel.expectedMessageCount(1);
+        cancel.expectedHeaderReceived("orderId", "42");
+
+        assertEquals(200,
+                callback(PARTICIPANT_PATH_COMPENSATE, "orderId=42&" + 
URL_COMPENSATION_KEY + "=" + encode("mock://cancel")));
+
+        cancel.assertIsSatisfied();
+    }
+
+    @Test
+    void completionIsRouted() throws Exception {
+        MockEndpoint confirm = getMockEndpoint("mock:confirm");
+        confirm.expectedMessageCount(1);
+        confirm.expectedHeaderReceived("orderId", "42");
+
+        assertEquals(200,
+                callback(PARTICIPANT_PATH_COMPLETE, "orderId=42&" + 
URL_COMPLETION_KEY + "=" + encode("mock://confirm")));
+
+        confirm.assertIsSatisfied();
+    }
+
+    @Test
+    void callbackToUnregisteredEndpointIsRejected() throws Exception {
+        MockEndpoint other = getMockEndpoint("mock:other");
+        other.expectedMessageCount(0);
+
+        assertEquals(500, callback(PARTICIPANT_PATH_COMPENSATE, 
URL_COMPENSATION_KEY + "=" + encode("mock://other")));
+
+        other.assertIsSatisfied();
+    }
+
+    private int callback(String path, String query) throws Exception {
+        HttpRequest request = HttpRequest.newBuilder(URI.create(
+                "http://localhost:"; + port.getPort() + 
DEFAULT_LOCAL_PARTICIPANT_CONTEXT_PATH + path + "?" + query))
+                .header(Exchange.SAGA_LONG_RUNNING_ACTION, 
"http://localhost/lra-coordinator/0_1";)
+                .PUT(HttpRequest.BodyPublishers.noBody())
+                .build();
+        return client.send(request, 
HttpResponse.BodyHandlers.discarding()).statusCode();
+    }
+
+    private static String encode(String value) {
+        return URLEncoder.encode(value, StandardCharsets.UTF_8);
+    }
+}
diff --git 
a/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaServiceTest.java
 
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaServiceTest.java
index 08c349de1bfe..fa74a03bfc5c 100644
--- 
a/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaServiceTest.java
+++ 
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaServiceTest.java
@@ -17,9 +17,13 @@
 package org.apache.camel.service.lra;
 
 import java.net.URL;
+import java.util.Map;
 import java.util.concurrent.CompletableFuture;
 
+import org.apache.camel.Endpoint;
 import org.apache.camel.Exchange;
+import org.apache.camel.saga.CamelSagaStep;
+import org.apache.camel.support.builder.ExpressionBuilder;
 import org.apache.camel.test.junit5.CamelTestSupport;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.DisplayName;
@@ -75,6 +79,32 @@ public class LRASagaServiceTest extends CamelTestSupport {
         Mockito.verify(client).newLRA(exchange);
     }
 
+    @DisplayName("Tests whether doStart() fails when the callback secret is 
set but blank")
+    @Test
+    void testBlankCallbackSecretFailsToStart() {
+        LRASagaService sagaService = new LRASagaService();
+        applyMockProperties(sagaService);
+        sagaService.setCallbackSecret(" ");
+
+        Assertions.assertThrows(IllegalArgumentException.class, 
sagaService::doStart);
+    }
+
+    @DisplayName("Tests whether registerStep() rejects a saga option named 
like a parameter of the callback URLs, in any case")
+    @Test
+    void testRegisterStepRejectsReservedOptionNames() {
+        LRASagaService sagaService = new LRASagaService();
+        Endpoint compensation = context().getEndpoint("direct:compensate1");
+        for (String name : new String[] {
+                "Camel-Saga-Compensate", "Camel-Saga-Complete", 
"Camel-Saga-Signature", "Camel-Saga-Lra",
+                "camel-saga-signature" }) {
+            CamelSagaStep step = new CamelSagaStep(
+                    compensation, null, Map.of(name, 
ExpressionBuilder.constantExpression("value")), null);
+
+            Assertions.assertThrows(IllegalArgumentException.class, () -> 
sagaService.registerStep(step), name);
+        }
+        Assertions.assertTrue(sagaService.getRegisteredURIs().isEmpty());
+    }
+
     private void applyMockProperties(LRASagaService sagaService) {
         sagaService.setCoordinatorUrl("mockCoordinatorUrl");
         sagaService.setLocalParticipantUrl("mockLocalParticipantUrl");
diff --git 
a/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRAConstants.java
 
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASignedCallbacksIT.java
similarity index 51%
copy from 
components/camel-lra/src/main/java/org/apache/camel/service/lra/LRAConstants.java
copy to 
components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASignedCallbacksIT.java
index 1c3feead0ec9..813a6c418293 100644
--- 
a/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRAConstants.java
+++ 
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASignedCallbacksIT.java
@@ -16,25 +16,15 @@
  */
 package org.apache.camel.service.lra;
 
-public final class LRAConstants {
-
-    public static final String DEFAULT_COORDINATOR_CONTEXT_PATH = 
"/lra-coordinator";
-    public static final String DEFAULT_LOCAL_PARTICIPANT_CONTEXT_PATH = 
"/lra-participant";
-
-    static final String COORDINATOR_PATH_START = "/start";
-    static final String COORDINATOR_PATH_CLOSE = "/close";
-    static final String COORDINATOR_PATH_CANCEL = "/cancel";
-
-    static final String PARTICIPANT_PATH_COMPENSATE = "/compensate";
-    static final String PARTICIPANT_PATH_COMPLETE = "/complete";
-
-    static final String HEADER_LINK = "Link";
-    static final String HEADER_TIME_LIMIT = "TimeLimit";
-
-    static final String URL_COMPENSATION_KEY = "Camel-Saga-Compensate";
-    static final String URL_COMPLETION_KEY = "Camel-Saga-Complete";
+/**
+ * Runs the saga option tests with signed participant callbacks, so the LRA 
coordinator calls back signed URLs.
+ */
+class LRASignedCallbacksIT extends LRAOptionsIT {
 
-    private LRAConstants() {
+    @Override
+    protected LRASagaService createLRASagaService() {
+        LRASagaService sagaService = super.createLRASagaService();
+        sagaService.setCallbackSecret("a-callback-secret");
+        return sagaService;
     }
-
 }
diff --git 
a/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRAUrlBuilderTest.java
 
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRAUrlBuilderTest.java
new file mode 100644
index 000000000000..3af26e8a1507
--- /dev/null
+++ 
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRAUrlBuilderTest.java
@@ -0,0 +1,110 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.service.lra;
+
+import java.lang.reflect.Method;
+import java.net.URI;
+import java.util.Map;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class LRAUrlBuilderTest {
+
+    @DisplayName("Tests that query() URL-encodes special characters in values")
+    @Test
+    void testQueryEncodesSpecialCharacters() {
+        String url = new LRAUrlBuilder()
+                .host("http://localhost:8080";)
+                .path("lra-participant")
+                .query("key", "value with spaces&and=special+chars#hash")
+                .build();
+
+        
assertTrue(url.contains("key=value+with+spaces%26and%3Dspecial%2Bchars%23hash"),
+                "Special characters must be URL-encoded in query values: " + 
url);
+    }
+
+    @DisplayName("Tests that compensation URI with query params is properly 
encoded")
+    @Test
+    void testCompensationUriWithQueryParams() {
+        String url = new LRAUrlBuilder()
+                .host("http://localhost:8080";)
+                .path("lra-participant")
+                
.compensation("seda:cancelOrder?concurrentConsumers=2&size=1000")
+                .build();
+
+        URI uri = URI.create(url);
+        assertNotNull(uri.getQuery(), "URL must have a valid query string");
+
+        String query = uri.getRawQuery();
+        assertTrue(query.contains("Camel-Saga-Compensate="),
+                "Query must contain the compensation key");
+        
assertTrue(query.contains("seda%3AcancelOrder%3FconcurrentConsumers%3D2%26size%3D1000"),
+                "Compensation URI must be fully encoded: " + query);
+    }
+
+    @DisplayName("Tests full round-trip: LRAUrlBuilder encodes, parseQuery 
decodes back to original")
+    @Test
+    void testCallbackUrlRoundTrip() throws Exception {
+        String compensationUri = 
"seda:cancelOrder?concurrentConsumers=2&size=1000";
+        String completionUri = "direct:complete";
+        String optionValue = "ACME&region=EU";
+
+        String url = new LRAUrlBuilder()
+                .host("http://localhost:8080";)
+                .path("lra-participant")
+                .query("myOption", optionValue)
+                .compensation(compensationUri)
+                .completion(completionUri)
+                .path("compensate")
+                .build();
+
+        URI uri = URI.create(url);
+        String rawQuery = uri.getRawQuery();
+
+        Method parseQuery = 
LRASagaRoutes.class.getDeclaredMethod("parseQuery", String.class);
+        parseQuery.setAccessible(true);
+        @SuppressWarnings("unchecked")
+        Map<String, String> parsed = (Map<String, String>) 
parseQuery.invoke(new LRASagaRoutes(null), rawQuery);
+
+        assertEquals(compensationUri, parsed.get("Camel-Saga-Compensate"),
+                "Compensation URI must survive encode/decode round-trip");
+        assertEquals(completionUri, parsed.get("Camel-Saga-Complete"),
+                "Completion URI must survive encode/decode round-trip");
+        assertEquals(optionValue, parsed.get("myOption"),
+                "Option value with special characters must survive 
encode/decode round-trip");
+    }
+
+    @DisplayName("Tests that simple direct: URIs still work after encoding")
+    @Test
+    void testSimpleDirectUri() {
+        String url = new LRAUrlBuilder()
+                .host("http://localhost:8080";)
+                .path("lra-participant")
+                .compensation("direct://saga1_participant1_compensate")
+                .completion("direct://saga1_participant1_complete")
+                .path("compensate")
+                .build();
+
+        URI uri = URI.create(url);
+        assertNotNull(uri.getQuery(), "URL must have a valid query string");
+    }
+}
diff --git 
a/core/camel-core-engine/src/main/docs/modules/eips/pages/saga-eip.adoc 
b/core/camel-core-engine/src/main/docs/modules/eips/pages/saga-eip.adoc
index 164b7c433e10..6e40be135258 100644
--- a/core/camel-core-engine/src/main/docs/modules/eips/pages/saga-eip.adoc
+++ b/core/camel-core-engine/src/main/docs/modules/eips/pages/saga-eip.adoc
@@ -139,6 +139,34 @@ 
sagaService.setLocalParticipantUrl("http://my-host-as-seen-by-lra-service:8080/c
 context.addService(sagaService);
 ----
 
+==== Signing the participant callbacks
+
+The participant endpoints are meant to be called by the LRA coordinator only.
+They call the compensation or completion endpoint of a saga step, with the 
saga options as headers, as described by the callback URL that the participant 
enlisted with the coordinator.
+
+When a callback secret is set on the LRA Saga Service, the participant signs 
the callback URLs it enlists, and verifies the signature when the coordinator 
calls them back.
+A callback with a missing or invalid signature is rejected with HTTP status 
403, and its compensation or completion endpoint is not called.
+The signature covers the saga step, its options and the LRA the URL was 
enlisted for, and the compensation or completion endpoint gets its 
`Long-Running-Action` header from the signed URL.
+A signed URL can still be called again for its own LRA by whoever obtains it, 
so keep the storage and the logs of the coordinator private.
+
+[source,java]
+----
+sagaService.setCallbackSecret(callbackSecret);
+----
+
+With Camel Main, the secret is set with the `camel.lra.callbackSecret` option, 
preferably resolved from an environment variable or a vault:
+
+[source,properties]
+----
+camel.lra.callbackSecret = {{env:LRA_CALLBACK_SECRET}}
+----
+
+Use a long random value, and the same value on all the instances of a 
participant, as the coordinator can call back any of them.
+A blank secret, for example from an empty environment variable, fails the 
start of the LRA Saga Service.
+The coordinator calls back the URLs that were enlisted when each saga step 
started, so set or change the secret when no saga is in progress: the callbacks 
of steps enlisted before the change are rejected otherwise.
+
+Without a callback secret, the callbacks are not verified, so the participant 
endpoints must only be reachable by the LRA coordinator, for example by not 
exposing the `/lra-participant` path outside the network of the coordinator.
+
 ==== Using the LRA Saga Service in Spring Boot
 
 Spring Boot users can use a simplified configuration model for the LRA Saga 
Service. Maven users can
diff --git 
a/core/camel-main/src/generated/java/org/apache/camel/main/LraConfigurationPropertiesConfigurer.java
 
b/core/camel-main/src/generated/java/org/apache/camel/main/LraConfigurationPropertiesConfigurer.java
index 3bdb1349e7c1..14f789dff4ff 100644
--- 
a/core/camel-main/src/generated/java/org/apache/camel/main/LraConfigurationPropertiesConfigurer.java
+++ 
b/core/camel-main/src/generated/java/org/apache/camel/main/LraConfigurationPropertiesConfigurer.java
@@ -23,6 +23,7 @@ public class LraConfigurationPropertiesConfigurer extends 
org.apache.camel.suppo
     static {
         Map<String, Object> map = new CaseInsensitiveMap();
         map.put("AllowedCoordinatorUrls", java.lang.String.class);
+        map.put("CallbackSecret", java.lang.String.class);
         map.put("CoordinatorContextPath", java.lang.String.class);
         map.put("CoordinatorUrl", java.lang.String.class);
         map.put("Enabled", boolean.class);
@@ -37,6 +38,8 @@ public class LraConfigurationPropertiesConfigurer extends 
org.apache.camel.suppo
         switch (ignoreCase ? name.toLowerCase() : name) {
         case "allowedcoordinatorurls":
         case "allowedCoordinatorUrls": 
target.setAllowedCoordinatorUrls(property(camelContext, java.lang.String.class, 
value)); return true;
+        case "callbacksecret":
+        case "callbackSecret": target.setCallbackSecret(property(camelContext, 
java.lang.String.class, value)); return true;
         case "coordinatorcontextpath":
         case "coordinatorContextPath": 
target.setCoordinatorContextPath(property(camelContext, java.lang.String.class, 
value)); return true;
         case "coordinatorurl":
@@ -60,6 +63,8 @@ public class LraConfigurationPropertiesConfigurer extends 
org.apache.camel.suppo
         switch (ignoreCase ? name.toLowerCase() : name) {
         case "allowedcoordinatorurls":
         case "allowedCoordinatorUrls": return java.lang.String.class;
+        case "callbacksecret":
+        case "callbackSecret": return java.lang.String.class;
         case "coordinatorcontextpath":
         case "coordinatorContextPath": return java.lang.String.class;
         case "coordinatorurl":
@@ -79,6 +84,8 @@ public class LraConfigurationPropertiesConfigurer extends 
org.apache.camel.suppo
         switch (ignoreCase ? name.toLowerCase() : name) {
         case "allowedcoordinatorurls":
         case "allowedCoordinatorUrls": return 
target.getAllowedCoordinatorUrls();
+        case "callbacksecret":
+        case "callbackSecret": return target.getCallbackSecret();
         case "coordinatorcontextpath":
         case "coordinatorContextPath": return 
target.getCoordinatorContextPath();
         case "coordinatorurl":
diff --git 
a/core/camel-main/src/generated/resources/META-INF/camel-main-configuration-metadata.json
 
b/core/camel-main/src/generated/resources/META-INF/camel-main-configuration-metadata.json
index 2f8df635ce4e..7a4973537e9b 100644
--- 
a/core/camel-main/src/generated/resources/META-INF/camel-main-configuration-metadata.json
+++ 
b/core/camel-main/src/generated/resources/META-INF/camel-main-configuration-metadata.json
@@ -190,6 +190,7 @@
     { "name": "camel.health.registryEnabled", "required": false, 
"description": "Whether registry health check is enabled", "sourceType": 
"org.apache.camel.main.HealthConfigurationProperties", "type": "boolean", 
"javaType": "java.lang.Boolean", "defaultValue": true, "secret": false },
     { "name": "camel.health.routesEnabled", "required": false, "description": 
"Whether routes health check is enabled", "sourceType": 
"org.apache.camel.main.HealthConfigurationProperties", "type": "boolean", 
"javaType": "java.lang.Boolean", "defaultValue": true, "secret": false },
     { "name": "camel.lra.allowedCoordinatorUrls", "required": false, 
"description": "Comma-separated URLs, including the context path, of further 
LRA coordinators whose LRAs may be joined, completed or compensated. By 
default, only an LRA hosted by the configured coordinator is accepted. Use it 
when participants reach the same coordinator under different URLs, for example 
with different host names.", "sourceType": 
"org.apache.camel.main.LraConfigurationProperties", "type": "string", "jav [...]
+    { "name": "camel.lra.callbackSecret", "required": false, "description": 
"Secret used to sign the participant callback URLs that are enlisted with the 
LRA coordinator, and to verify them when the coordinator calls back. When set, 
a compensation or completion callback with a missing or invalid signature is 
rejected with HTTP 403. All the instances of a participant must use the same 
secret.", "sourceType": "org.apache.camel.main.LraConfigurationProperties", 
"type": "string", "javaType": [...]
     { "name": "camel.lra.coordinatorContextPath", "required": false, 
"description": "The context-path for the LRA coordinator. Is default 
\/lra-coordinator", "sourceType": 
"org.apache.camel.main.LraConfigurationProperties", "type": "string", 
"javaType": "java.lang.String", "defaultValue": "\/lra-coordinator", "secret": 
false },
     { "name": "camel.lra.coordinatorUrl", "required": false, "description": 
"The URL for the LRA coordinator service that orchestrates the transactions", 
"sourceType": "org.apache.camel.main.LraConfigurationProperties", "type": 
"string", "javaType": "java.lang.String", "secret": false },
     { "name": "camel.lra.enabled", "required": false, "description": "To 
enable Saga LRA", "sourceType": 
"org.apache.camel.main.LraConfigurationProperties", "type": "boolean", 
"javaType": "boolean", "defaultValue": false, "secret": false },
diff --git a/core/camel-main/src/main/docs/main.adoc 
b/core/camel-main/src/main/docs/main.adoc
index 0aa7f14d4836..f525c7652cb2 100644
--- a/core/camel-main/src/main/docs/main.adoc
+++ b/core/camel-main/src/main/docs/main.adoc
@@ -674,12 +674,13 @@ The camel.resilience4j supports 21 options, which are 
listed below.
 
 
 === Camel Saga EIP (Long Running Actions) configurations
-The camel.lra supports 6 options, which are listed below.
+The camel.lra supports 7 options, which are listed below.
 
 [width="100%",cols="2,5,^1,2",options="header"]
 |===
 | Name | Description | Default | Type
 | *camel.lra.allowedCoordinator{zwsp}Urls* | Comma-separated URLs, including 
the context path, of further LRA coordinators whose LRAs may be joined, 
completed or compensated. By default, only an LRA hosted by the configured 
coordinator is accepted. Use it when participants reach the same coordinator 
under different URLs, for example with different host names. |  | String
+| *camel.lra.callbackSecret* | Secret used to sign the participant callback 
URLs that are enlisted with the LRA coordinator, and to verify them when the 
coordinator calls back. When set, a compensation or completion callback with a 
missing or invalid signature is rejected with HTTP 403. All the instances of a 
participant must use the same secret. |  | String
 | *camel.lra.coordinatorContext{zwsp}Path* | The context-path for the LRA 
coordinator. Is default /lra-coordinator | /lra-coordinator | String
 | *camel.lra.coordinatorUrl* | The URL for the LRA coordinator service that 
orchestrates the transactions |  | String
 | *camel.lra.enabled* | To enable Saga LRA | false | boolean
diff --git 
a/core/camel-main/src/main/java/org/apache/camel/main/LraConfigurationProperties.java
 
b/core/camel-main/src/main/java/org/apache/camel/main/LraConfigurationProperties.java
index 6ae967f1a0a8..46f191e74ba7 100644
--- 
a/core/camel-main/src/main/java/org/apache/camel/main/LraConfigurationProperties.java
+++ 
b/core/camel-main/src/main/java/org/apache/camel/main/LraConfigurationProperties.java
@@ -38,6 +38,8 @@ public class LraConfigurationProperties implements 
BootstrapCloseable {
     private String localParticipantContextPath = "/lra-participant";
     @Metadata(label = "security")
     private String allowedCoordinatorUrls;
+    @Metadata(label = "security", secret = true)
+    private String callbackSecret;
 
     public LraConfigurationProperties(MainConfigurationProperties parent) {
         this.parent = parent;
@@ -124,6 +126,19 @@ public class LraConfigurationProperties implements 
BootstrapCloseable {
         this.allowedCoordinatorUrls = allowedCoordinatorUrls;
     }
 
+    public String getCallbackSecret() {
+        return callbackSecret;
+    }
+
+    /**
+     * Secret used to sign the participant callback URLs that are enlisted 
with the LRA coordinator, and to verify them
+     * when the coordinator calls back. When set, a compensation or completion 
callback with a missing or invalid
+     * signature is rejected with HTTP 403. All the instances of a participant 
must use the same secret.
+     */
+    public void setCallbackSecret(String callbackSecret) {
+        this.callbackSecret = callbackSecret;
+    }
+
     /**
      * The URL for the LRA coordinator service that orchestrates the 
transactions
      */
@@ -173,4 +188,13 @@ public class LraConfigurationProperties implements 
BootstrapCloseable {
         return this;
     }
 
+    /**
+     * Secret used to sign the participant callback URLs that are enlisted 
with the LRA coordinator, and to verify them
+     * when the coordinator calls back.
+     */
+    public LraConfigurationProperties withCallbackSecret(String 
callbackSecret) {
+        this.callbackSecret = callbackSecret;
+        return this;
+    }
+
 }
diff --git 
a/core/camel-util/src/main/java/org/apache/camel/util/SensitiveUtils.java 
b/core/camel-util/src/main/java/org/apache/camel/util/SensitiveUtils.java
index 2f06aa898cac..425d2d39dc5f 100644
--- a/core/camel-util/src/main/java/org/apache/camel/util/SensitiveUtils.java
+++ b/core/camel-util/src/main/java/org/apache/camel/util/SensitiveUtils.java
@@ -48,6 +48,7 @@ public final class SensitiveUtils {
                     "azureclientsecret",
                     "blobaccesskey",
                     "blobstoragesharedkeycredential",
+                    "callbacksecret",
                     "certresourcepassword",
                     "clientid",
                     "clientsecret",
@@ -139,6 +140,7 @@ public final class SensitiveUtils {
                                                     + 
"|\\Qazureclientsecret\\E"
                                                     + "|\\Qblobaccesskey\\E"
                                                     + 
"|\\Qblobstoragesharedkeycredential\\E"
+                                                    + "|\\Qcallbacksecret\\E"
                                                     + 
"|\\Qcertresourcepassword\\E"
                                                     + "|\\Qclientid\\E"
                                                     + "|\\Qclientsecret\\E"
diff --git 
a/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/UpdateSensitizeHelper.java
 
b/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/UpdateSensitizeHelper.java
index 0998272cd9b1..48a239825316 100644
--- 
a/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/UpdateSensitizeHelper.java
+++ 
b/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/UpdateSensitizeHelper.java
@@ -59,7 +59,9 @@ public class UpdateSensitizeHelper extends 
AbstractGeneratorMojo {
     // extra keys that are regarded as secret which may not yet been in any 
component
     // they MUST be in lowercase and without a dash
     private static final String[] EXTRA_KEYS
-            = new String[] { "apipassword", "apiuser", "apiusername", 
"api_key", "api_secret", "secret", "keystorePassword" };
+            = new String[] {
+                    "apipassword", "apiuser", "apiusername", "api_key", 
"api_secret", "callbacksecret", "secret",
+                    "keystorePassword" };
 
     @Parameter(defaultValue = 
"${project.basedir}/src/generated/resources/org/apache/camel/catalog/")
     protected File jsonDir;

Reply via email to