diff --git a/components/camel-spiffe/src/main/docs/spiffe-component.adoc b/components/camel-spiffe/src/main/docs/spiffe-component.adoc
index 61eb1bd1009d3..93f8dafde4358 100644
--- a/components/camel-spiffe/src/main/docs/spiffe-component.adoc
+++ b/components/camel-spiffe/src/main/docs/spiffe-component.adoc
@@ -86,4 +86,34 @@ from("direct:start")
.to("http://backend.example.org/api");
------------------------------------------------------------
+== Mutual TLS with SPIFFE (SSLContextParameters)
+
+For X.509-based zero-trust mTLS, the component provides
+`org.apache.camel.component.spiffe.SpiffeSSLContextParameters`, an `SSLContextParameters` whose `SSLContext` is
+backed by the SPIFFE Workload API. The X.509-SVID and trust bundles are fetched live and rotated automatically,
+so any Camel component that accepts an `sslContextParameters` reference (camel-http, camel-netty-http,
+camel-jetty, camel-vertx-http, ...) can obtain SPIFFE mTLS.
+
+Peer authentication must be constrained explicitly: set `acceptedSpiffeIds` to an allow-list of peer SPIFFE IDs,
+or `acceptAnySpiffeId=true` to accept any SVID that validates against the trust bundle. The two are mutually
+exclusive, and setting neither fails closed.
+
+Because it extends `SSLContextParameters`, the inherited configuration is still honoured: set
+`serverParameters.clientAuthentication` (for a server that must require client certificates), `cipherSuites` and
+`secureSocketProtocols` as usual, and the base handshake protocol comes from `secureSocketProtocol` (default
+`TLSv1.3`). The underlying `X509Source` is created lazily (bounded by `initTimeout`, default 30s), closed on
+`CamelContext` shutdown, and the cached context is invalidated at the same time so a restarted context rebuilds a
+fresh source.
+
+[source,java]
+------------------------------------------------------------
+SpiffeSSLContextParameters ssl = new SpiffeSSLContextParameters();
+// ssl.setSpiffeSocketPath("unix:///tmp/spire-agent/public/api.sock"); // or SPIFFE_ENDPOINT_SOCKET
+ssl.setAcceptedSpiffeIds("spiffe://example.org/backend");
+getCamelContext().getRegistry().bind("spiffeSsl", ssl);
+
+from("direct:start")
+ .to("https://backend.example.org/api?sslContextParameters=#spiffeSsl");
+------------------------------------------------------------
+
include::spring-boot:partial$starter.adoc[]
diff --git a/components/camel-spiffe/src/main/java/org/apache/camel/component/spiffe/SpiffeSSLContextParameters.java b/components/camel-spiffe/src/main/java/org/apache/camel/component/spiffe/SpiffeSSLContextParameters.java
new file mode 100644
index 0000000000000..d574a2ffb1182
--- /dev/null
+++ b/components/camel-spiffe/src/main/java/org/apache/camel/component/spiffe/SpiffeSSLContextParameters.java
@@ -0,0 +1,270 @@
+/*
+ * 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.component.spiffe;
+
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+import java.time.Duration;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+import javax.net.ssl.SSLContext;
+
+import io.spiffe.provider.SpiffeSslContextFactory;
+import io.spiffe.provider.SpiffeSslContextFactory.SslContextOptions;
+import io.spiffe.spiffeid.SpiffeId;
+import io.spiffe.workloadapi.DefaultX509Source;
+import io.spiffe.workloadapi.DefaultX509Source.X509SourceOptions;
+import io.spiffe.workloadapi.X509Source;
+import org.apache.camel.CamelContext;
+import org.apache.camel.RuntimeCamelException;
+import org.apache.camel.Service;
+import org.apache.camel.spi.Metadata;
+import org.apache.camel.support.jsse.SSLContextParameters;
+import org.apache.camel.util.ObjectHelper;
+
+/**
+ * An {@link SSLContextParameters} whose {@link SSLContext} is backed by the SPIFFE Workload API.
+ *
+ * The X.509-SVID (certificate chain and private key) and the trust bundles are sourced live from a SPIFFE Workload API
+ * endpoint - for example the one exposed by a SPIRE agent - and rotated automatically, providing zero-trust mutual TLS
+ * to any Camel component that accepts an {@code sslContextParameters} reference (camel-http, camel-netty-http,
+ * camel-jetty, camel-vertx-http, ...).
+ *
+ * Peer authentication must be constrained explicitly: set {@link #setAcceptedSpiffeIds(String)} to an allow-list of
+ * peer SPIFFE IDs, or {@link #setAcceptAnySpiffeId(boolean)} to {@code true} to accept any SVID that validates against
+ * the trust bundle. Setting both is rejected, and setting neither fails closed.
+ *
+ * The inherited {@code SSLContextParameters} configuration still applies: the built context is wrapped with the same
+ * decorator as the parent, so {@code serverParameters.clientAuthentication}, {@code cipherSuites} and
+ * {@code secureSocketProtocols} are honoured. The base handshake protocol is taken from
+ * {@link #getSecureSocketProtocol()} (default {@code TLSv1.3}).
+ *
+ * The underlying {@code X509Source} is created lazily on the first call, closed when the {@link CamelContext} shuts
+ * down, and the cached context is invalidated at the same time so a restarted context rebuilds a fresh source.
+ */
+public class SpiffeSSLContextParameters extends SSLContextParameters {
+
+ private static final String DEFAULT_PROTOCOL = "TLSv1.3";
+
+ @Metadata(label = "security",
+ description = "Path to the SPIFFE Workload API endpoint (for example unix:///tmp/agent.sock)."
+ + " When not set, the SPIFFE_ENDPOINT_SOCKET environment variable is used.")
+ private String spiffeSocketPath;
+ @Metadata(label = "security",
+ description = "Comma-separated allow-list of peer SPIFFE IDs to accept during the TLS handshake"
+ + " (for example spiffe://example.org/client). Mutually exclusive with acceptAnySpiffeId.")
+ private String acceptedSpiffeIds;
+ @Metadata(label = "security", defaultValue = "false",
+ description = "Accept any peer SPIFFE ID that validates against the trust bundle, instead of an"
+ + " explicit acceptedSpiffeIds allow-list. Use with care; mutually exclusive with"
+ + " acceptedSpiffeIds.")
+ private boolean acceptAnySpiffeId;
+ @Metadata(label = "security", defaultValue = "30000",
+ description = "Timeout in milliseconds to wait for the first SVID from the Workload API when creating"
+ + " the source, so a slow or unreachable endpoint cannot block indefinitely.")
+ private long initTimeout = 30000L;
+
+ private volatile SSLContext sslContext;
+
+ public String getSpiffeSocketPath() {
+ return spiffeSocketPath;
+ }
+
+ /**
+ * Path to the SPIFFE Workload API endpoint. When not set, the {@code SPIFFE_ENDPOINT_SOCKET} environment variable
+ * is used.
+ */
+ public void setSpiffeSocketPath(String spiffeSocketPath) {
+ this.spiffeSocketPath = spiffeSocketPath;
+ }
+
+ public String getAcceptedSpiffeIds() {
+ return acceptedSpiffeIds;
+ }
+
+ /**
+ * Comma-separated allow-list of peer SPIFFE IDs to accept during the TLS handshake. Mutually exclusive with
+ * {@code acceptAnySpiffeId}.
+ */
+ public void setAcceptedSpiffeIds(String acceptedSpiffeIds) {
+ this.acceptedSpiffeIds = acceptedSpiffeIds;
+ }
+
+ public boolean isAcceptAnySpiffeId() {
+ return acceptAnySpiffeId;
+ }
+
+ /**
+ * Accept any peer SPIFFE ID that validates against the trust bundle, instead of an explicit allow-list. Mutually
+ * exclusive with {@code acceptedSpiffeIds}.
+ */
+ public void setAcceptAnySpiffeId(boolean acceptAnySpiffeId) {
+ this.acceptAnySpiffeId = acceptAnySpiffeId;
+ }
+
+ public long getInitTimeout() {
+ return initTimeout;
+ }
+
+ /**
+ * Timeout in milliseconds to wait for the first SVID from the Workload API when creating the source (default
+ * {@code 30000}).
+ */
+ public void setInitTimeout(long initTimeout) {
+ this.initTimeout = initTimeout;
+ }
+
+ @Override
+ public SSLContext createSSLContext(CamelContext camelContext) throws GeneralSecurityException, IOException {
+ if (camelContext != null) {
+ setCamelContext(camelContext);
+ }
+ SSLContext existing = sslContext;
+ if (existing != null) {
+ return existing;
+ }
+
+ String acceptedIds = parsePropertyValue(acceptedSpiffeIds);
+ boolean haveAllowList = ObjectHelper.isNotEmpty(acceptedIds);
+ if (acceptAnySpiffeId && haveAllowList) {
+ throw new IllegalStateException(
+ "acceptAnySpiffeId and acceptedSpiffeIds are mutually exclusive; set only one");
+ }
+ if (!acceptAnySpiffeId && !haveAllowList) {
+ throw new IllegalStateException(
+ "A SPIFFE-backed SSLContext requires either acceptAnySpiffeId=true or a non-empty"
+ + " acceptedSpiffeIds allow-list");
+ }
+
+ synchronized (this) {
+ if (sslContext != null) {
+ return sslContext;
+ }
+ // parse the (cheap, non-IO) allow-list before opening a Workload API connection, so a malformed id
+ // fails fast without a wasted round-trip
+ Set acceptedSet = haveAllowList ? parseSpiffeIds(acceptedIds) : Set.of();
+ if (!acceptAnySpiffeId && acceptedSet.isEmpty()) {
+ throw new IllegalStateException(
+ "acceptedSpiffeIds did not contain any SPIFFE ID after trimming");
+ }
+
+ String protocol = parsePropertyValue(getSecureSocketProtocol());
+ if (ObjectHelper.isEmpty(protocol)) {
+ protocol = DEFAULT_PROTOCOL;
+ }
+
+ X509Source source = createX509Source();
+ try {
+ SslContextOptions.SslContextOptionsBuilder options
+ = SslContextOptions.builder().x509Source(source).sslProtocol(protocol);
+ if (acceptAnySpiffeId) {
+ options.acceptAnySpiffeId();
+ } else {
+ options.acceptedSpiffeIdsSupplier(() -> acceptedSet);
+ }
+ SSLContext spiffeContext = SpiffeSslContextFactory.getSslContext(options.build());
+ // wrap with the same decorator the parent uses, so clientAuthentication / cipherSuites /
+ // secureSocketProtocols configured on this instance are still applied to every engine and socket
+ SSLContext decorated = new SSLContextDecorator(
+ new SSLContextSpiDecorator(
+ spiffeContext,
+ getSSLEngineConfigurers(spiffeContext),
+ getSSLSocketFactoryConfigurers(spiffeContext),
+ getSSLServerSocketFactoryConfigurers(spiffeContext)));
+ registerForShutdown(camelContext, source);
+ this.sslContext = decorated;
+ return decorated;
+ } catch (GeneralSecurityException | RuntimeException e) {
+ closeQuietly(source);
+ throw e;
+ }
+ }
+ }
+
+ private X509Source createX509Source() {
+ try {
+ X509SourceOptions.Builder builder = X509SourceOptions.builder().initTimeout(Duration.ofMillis(initTimeout));
+ if (ObjectHelper.isNotEmpty(spiffeSocketPath)) {
+ builder.spiffeSocketPath(parsePropertyValue(spiffeSocketPath));
+ }
+ return DefaultX509Source.newSource(builder.build());
+ } catch (Exception e) {
+ throw new RuntimeCamelException(
+ "Could not create the SPIFFE X509Source (is a Workload API endpoint reachable?)", e);
+ }
+ }
+
+ private void registerForShutdown(CamelContext camelContext, X509Source source) {
+ // fall back to the context this instance already knows (JsseParameters is CamelContextAware), so callers
+ // that pass null - e.g. some HTTP server factories - still get the source closed on shutdown
+ CamelContext context = camelContext != null ? camelContext : getCamelContext();
+ if (context != null) {
+ try {
+ context.addService(new X509SourceService(source));
+ } catch (Exception e) {
+ throw new RuntimeCamelException(e);
+ }
+ }
+ }
+
+ static Set parseSpiffeIds(String csv) {
+ Set ids = new LinkedHashSet<>();
+ for (String part : csv.split(",")) {
+ String trimmed = part.trim();
+ if (!trimmed.isEmpty()) {
+ ids.add(SpiffeId.parse(trimmed));
+ }
+ }
+ return ids;
+ }
+
+ private static void closeQuietly(X509Source source) {
+ if (source != null) {
+ try {
+ source.close();
+ } catch (Exception e) {
+ // ignore on cleanup
+ }
+ }
+ }
+
+ /**
+ * Closes the {@link X509Source} and invalidates the cached context when the {@link CamelContext} shuts down, so the
+ * Workload API watcher is not leaked and a restarted context rebuilds a fresh source.
+ */
+ private final class X509SourceService implements Service {
+ private final X509Source source;
+
+ X509SourceService(X509Source source) {
+ this.source = source;
+ }
+
+ @Override
+ public void start() {
+ // nothing to start; the X509Source is already active once created
+ }
+
+ @Override
+ public void stop() {
+ closeQuietly(source);
+ synchronized (SpiffeSSLContextParameters.this) {
+ sslContext = null;
+ }
+ }
+ }
+}
diff --git a/components/camel-spiffe/src/test/java/org/apache/camel/component/spiffe/SpiffeSSLContextParametersTest.java b/components/camel-spiffe/src/test/java/org/apache/camel/component/spiffe/SpiffeSSLContextParametersTest.java
new file mode 100644
index 0000000000000..5fffc19c84f1d
--- /dev/null
+++ b/components/camel-spiffe/src/test/java/org/apache/camel/component/spiffe/SpiffeSSLContextParametersTest.java
@@ -0,0 +1,76 @@
+/*
+ * 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.component.spiffe;
+
+import java.util.Set;
+
+import io.spiffe.spiffeid.SpiffeId;
+import org.apache.camel.RuntimeCamelException;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class SpiffeSSLContextParametersTest {
+
+ @Test
+ void createSSLContextFailsClosedWithoutTrustConfig() {
+ SpiffeSSLContextParameters params = new SpiffeSSLContextParameters();
+ // neither acceptAnySpiffeId nor acceptedSpiffeIds set -> must refuse to build a context
+ assertThatThrownBy(() -> params.createSSLContext(null))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("acceptAnySpiffeId");
+ }
+
+ @Test
+ void acceptedSpiffeIdsAreParsedAndBlanksDropped() {
+ Set ids = SpiffeSSLContextParameters.parseSpiffeIds(
+ "spiffe://example.org/a, , spiffe://example.org/b");
+ assertThat(ids).extracting(SpiffeId::toString)
+ .containsExactly("spiffe://example.org/a", "spiffe://example.org/b");
+ }
+
+ @Test
+ void createSSLContextWrapsAnUnreachableWorkloadApi() {
+ SpiffeSSLContextParameters params = new SpiffeSSLContextParameters();
+ params.setAcceptAnySpiffeId(true);
+ // an unsupported scheme fails synchronously while parsing the endpoint address (no network wait)
+ params.setSpiffeSocketPath("http://localhost");
+ assertThatThrownBy(() -> params.createSSLContext(null))
+ .isInstanceOf(RuntimeCamelException.class)
+ .hasMessageContaining("X509Source");
+ }
+
+ @Test
+ void acceptAnyAndAllowListAreMutuallyExclusive() {
+ SpiffeSSLContextParameters params = new SpiffeSSLContextParameters();
+ params.setAcceptAnySpiffeId(true);
+ params.setAcceptedSpiffeIds("spiffe://example.org/a");
+ assertThatThrownBy(() -> params.createSSLContext(null))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("mutually exclusive");
+ }
+
+ @Test
+ void blankOnlyAcceptedIdsAreRejected() {
+ SpiffeSSLContextParameters params = new SpiffeSSLContextParameters();
+ params.setAcceptedSpiffeIds(", ,");
+ assertThatThrownBy(() -> params.createSSLContext(null))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("did not contain any SPIFFE ID");
+ }
+}