tenantClaimPaths = new ArrayList<>(List.of("tenant_ids"));
+}
diff --git a/src/main/java/org/fireflyframework/security/webflux/bridge/PrincipalHeaderBridgeWebFilter.java b/src/main/java/org/fireflyframework/security/webflux/bridge/PrincipalHeaderBridgeWebFilter.java
new file mode 100644
index 0000000..7c67af0
--- /dev/null
+++ b/src/main/java/org/fireflyframework/security/webflux/bridge/PrincipalHeaderBridgeWebFilter.java
@@ -0,0 +1,97 @@
+/*
+ * Copyright 2024-2026 Firefly Software Foundation
+ *
+ * Licensed 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.fireflyframework.security.webflux.bridge;
+
+import org.fireflyframework.security.api.domain.SecurityPrincipal;
+import org.fireflyframework.security.spi.SecurityContextPort;
+import org.fireflyframework.security.spi.TenantResolverPort;
+import org.springframework.core.Ordered;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.server.ResponseStatusException;
+import org.springframework.web.server.ServerWebExchange;
+import org.springframework.web.server.WebFilter;
+import org.springframework.web.server.WebFilterChain;
+import reactor.core.publisher.Mono;
+
+/**
+ * Derives trusted internal request headers from the validated token, never from
+ * the client.
+ *
+ * Runs right after Spring Security has authenticated the request (order {@value #ORDER}): it
+ * reads the principal, resolves the tenant via {@link TenantResolverPort} and
+ * overwrites the configured tenant/user/roles headers on the downstream request
+ * before it reaches a controller, so any client-supplied value is discarded. Unauthenticated
+ * (permit-all) requests pass through untouched. When a tenant is required but unresolved, the
+ * request fails closed with 403.
+ */
+public class PrincipalHeaderBridgeWebFilter implements WebFilter, Ordered {
+
+ /** After Spring Security's {@code WebFilterChainProxy} (-100), before the controller. */
+ public static final int ORDER = -90;
+
+ private final SecurityContextPort securityContext;
+ private final TenantResolverPort tenantResolver;
+ private final PrincipalHeaderBridgeProperties properties;
+
+ public PrincipalHeaderBridgeWebFilter(SecurityContextPort securityContext,
+ TenantResolverPort tenantResolver,
+ PrincipalHeaderBridgeProperties properties) {
+ this.securityContext = securityContext;
+ this.tenantResolver = tenantResolver;
+ this.properties = properties;
+ }
+
+ @Override
+ public Mono filter(ServerWebExchange exchange, WebFilterChain chain) {
+ // Map the principal to a mutated exchange, default to the original when there is no principal
+ // (permit-all paths), then run the chain exactly once. Flat-mapping onto chain.filter (a
+ // Mono that completes empty) would re-trigger defaultIfEmpty and run the chain twice.
+ // The inner Mono fails closed (403) on an unresolvable required tenant.
+ return securityContext.currentPrincipal()
+ .flatMap(principal -> withTrustedHeaders(exchange, principal))
+ .defaultIfEmpty(exchange)
+ .flatMap(chain::filter);
+ }
+
+ private Mono withTrustedHeaders(ServerWebExchange exchange, SecurityPrincipal principal) {
+ return tenantResolver.resolveTenant(principal)
+ .map(tenant -> mutate(exchange, principal, tenant))
+ .switchIfEmpty(Mono.defer(() -> properties.isTenantRequired()
+ ? Mono.error(new ResponseStatusException(HttpStatus.FORBIDDEN,
+ "tenant_resolution_failed: token does not carry exactly one tenant"))
+ : Mono.just(mutate(exchange, principal, null))));
+ }
+
+ private ServerWebExchange mutate(ServerWebExchange exchange, SecurityPrincipal principal, String tenant) {
+ return exchange.mutate()
+ .request(r -> r.headers(headers -> {
+ if (tenant != null) {
+ headers.set(properties.getTenantHeader(), tenant);
+ } else {
+ headers.remove(properties.getTenantHeader());
+ }
+ headers.set(properties.getUserHeader(), principal.subject());
+ headers.set(properties.getRolesHeader(), String.join(",", principal.authorities()));
+ }))
+ .build();
+ }
+
+ @Override
+ public int getOrder() {
+ return ORDER;
+ }
+}
diff --git a/src/main/java/org/fireflyframework/security/webflux/bridge/WebFluxBridgeAutoConfiguration.java b/src/main/java/org/fireflyframework/security/webflux/bridge/WebFluxBridgeAutoConfiguration.java
new file mode 100644
index 0000000..1f817ee
--- /dev/null
+++ b/src/main/java/org/fireflyframework/security/webflux/bridge/WebFluxBridgeAutoConfiguration.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2024-2026 Firefly Software Foundation
+ *
+ * Licensed 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.fireflyframework.security.webflux.bridge;
+
+import org.fireflyframework.security.core.tenant.ConfigurableTenantResolver;
+import org.fireflyframework.security.spi.SecurityContextPort;
+import org.fireflyframework.security.spi.TenantResolverPort;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+
+/**
+ * Opt-in auto-configuration of the principal→header bridge. Disabled by default
+ * ({@code firefly.security.webflux.header-bridge.enabled=false}) so no existing consumer is
+ * affected; when enabled it contributes a default {@link TenantResolverPort} (unless the product
+ * supplies its own) and the {@link PrincipalHeaderBridgeWebFilter}. Both beans are
+ * {@link ConditionalOnMissingBean} so applications can override either piece.
+ */
+@AutoConfiguration
+@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE)
+@ConditionalOnProperty(prefix = "firefly.security.webflux.header-bridge", name = "enabled", havingValue = "true")
+@EnableConfigurationProperties(PrincipalHeaderBridgeProperties.class)
+public class WebFluxBridgeAutoConfiguration {
+
+ @Bean
+ @ConditionalOnMissingBean
+ public TenantResolverPort fireflyTenantResolverPort(PrincipalHeaderBridgeProperties properties) {
+ return new ConfigurableTenantResolver(properties.getTenantClaimPaths());
+ }
+
+ @Bean
+ @ConditionalOnMissingBean
+ public PrincipalHeaderBridgeWebFilter fireflyPrincipalHeaderBridgeWebFilter(
+ SecurityContextPort securityContext,
+ TenantResolverPort tenantResolver,
+ PrincipalHeaderBridgeProperties properties) {
+ return new PrincipalHeaderBridgeWebFilter(securityContext, tenantResolver, properties);
+ }
+}
diff --git a/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
new file mode 100644
index 0000000..e5a9229
--- /dev/null
+++ b/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -0,0 +1 @@
+org.fireflyframework.security.webflux.bridge.WebFluxBridgeAutoConfiguration
diff --git a/src/test/java/org/fireflyframework/security/webflux/bridge/PrincipalHeaderBridgeWebFilterTest.java b/src/test/java/org/fireflyframework/security/webflux/bridge/PrincipalHeaderBridgeWebFilterTest.java
new file mode 100644
index 0000000..0aa14e1
--- /dev/null
+++ b/src/test/java/org/fireflyframework/security/webflux/bridge/PrincipalHeaderBridgeWebFilterTest.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright 2024-2026 Firefly Software Foundation
+ *
+ * Licensed 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.fireflyframework.security.webflux.bridge;
+
+import org.fireflyframework.security.api.domain.SecurityPrincipal;
+import org.fireflyframework.security.core.tenant.ConfigurableTenantResolver;
+import org.fireflyframework.security.spi.SecurityContextPort;
+import org.fireflyframework.security.spi.TenantResolverPort;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpHeaders;
+import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
+import org.springframework.mock.web.server.MockServerWebExchange;
+import org.springframework.web.server.ResponseStatusException;
+import org.springframework.web.server.ServerWebExchange;
+import org.springframework.web.server.WebFilterChain;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class PrincipalHeaderBridgeWebFilterTest {
+
+ private final PrincipalHeaderBridgeProperties properties = new PrincipalHeaderBridgeProperties();
+ private final TenantResolverPort tenantResolver = new ConfigurableTenantResolver(List.of("tenant_ids"));
+
+ /** Records how many times it ran and the exchange it saw — to assert single execution + mutation. */
+ private static final class CapturingChain implements WebFilterChain {
+ final AtomicInteger calls = new AtomicInteger();
+ final AtomicReference captured = new AtomicReference<>();
+
+ @Override
+ public Mono filter(ServerWebExchange exchange) {
+ calls.incrementAndGet();
+ captured.set(exchange);
+ return Mono.empty();
+ }
+ }
+
+ private static SecurityContextPort principalPort(SecurityPrincipal principal) {
+ return () -> principal == null ? Mono.empty() : Mono.just(principal);
+ }
+
+ private PrincipalHeaderBridgeWebFilter filter(SecurityContextPort port) {
+ return new PrincipalHeaderBridgeWebFilter(port, tenantResolver, properties);
+ }
+
+ @Test
+ void overwritesTrustedHeadersFromToken() {
+ SecurityPrincipal principal = SecurityPrincipal.builder()
+ .subject("user-1")
+ .authorities(Set.of("idp-admin"))
+ .claims(Map.of("tenant_ids", List.of("tenant-a")))
+ .build();
+ MockServerWebExchange exchange = MockServerWebExchange.from(
+ MockServerHttpRequest.get("/api/x").header("X-Tenant-Id", "evil")); // client spoof
+ CapturingChain chain = new CapturingChain();
+
+ StepVerifier.create(filter(principalPort(principal)).filter(exchange, chain)).verifyComplete();
+
+ assertThat(chain.calls).hasValue(1);
+ HttpHeaders headers = chain.captured.get().getRequest().getHeaders();
+ assertThat(headers.getFirst("X-Tenant-Id")).isEqualTo("tenant-a"); // spoof overwritten
+ assertThat(headers.getFirst("X-User-Id")).isEqualTo("user-1");
+ assertThat(headers.getFirst("X-User-Roles")).isEqualTo("idp-admin");
+ }
+
+ @Test
+ void failsClosedWhenTenantUnresolved() {
+ SecurityPrincipal principal = SecurityPrincipal.builder()
+ .subject("user-1")
+ .claims(Map.of("tenant_ids", List.of("a", "b"))) // >1 -> empty -> 403
+ .build();
+ MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/api/x"));
+ CapturingChain chain = new CapturingChain();
+
+ StepVerifier.create(filter(principalPort(principal)).filter(exchange, chain))
+ .expectErrorSatisfies(e -> assertThat(((ResponseStatusException) e).getStatusCode().value()).isEqualTo(403))
+ .verify();
+
+ assertThat(chain.calls).hasValue(0); // fail-closed: chain never runs
+ }
+
+ @Test
+ void passesThroughUntouchedWhenUnauthenticated() {
+ MockServerWebExchange exchange = MockServerWebExchange.from(
+ MockServerHttpRequest.get("/actuator/health").header("X-Tenant-Id", "client"));
+ CapturingChain chain = new CapturingChain();
+
+ StepVerifier.create(filter(principalPort(null)).filter(exchange, chain)).verifyComplete();
+
+ assertThat(chain.calls).hasValue(1);
+ assertThat(chain.captured.get().getRequest().getHeaders().getFirst("X-Tenant-Id")).isEqualTo("client");
+ }
+}