diff --git a/src/main/java/org/fireflyframework/security/core/tenant/ConfigurableTenantResolver.java b/src/main/java/org/fireflyframework/security/core/tenant/ConfigurableTenantResolver.java
new file mode 100644
index 0000000..994aecc
--- /dev/null
+++ b/src/main/java/org/fireflyframework/security/core/tenant/ConfigurableTenantResolver.java
@@ -0,0 +1,55 @@
+/*
+ * 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.core.tenant;
+
+import org.fireflyframework.security.api.domain.SecurityPrincipal;
+import org.fireflyframework.security.core.authority.ClaimPaths;
+import org.fireflyframework.security.spi.TenantResolverPort;
+import reactor.core.publisher.Mono;
+
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Default {@link TenantResolverPort}: reads the tenant id from the validated token claims at the
+ * configured dot-paths (default {@code tenant_ids}, coalesced across paths).
+ *
+ *
Single-tenant and fail-closed: it emits the id only when the token carries
+ * exactly one tenant, and {@link Mono#empty()} for 0 or >1 so the caller decides how to fail
+ * (the header bridge turns empty into a 403 when a tenant is required). Picking one silently is
+ * never an option — that would risk acting on the wrong tenant. The future multi-tenant change
+ * (active tenant chosen from the request and validated against the set) is a change to the caller,
+ * not to this single-valued port.
+ */
+public class ConfigurableTenantResolver implements TenantResolverPort {
+
+ private final List claimPaths;
+
+ public ConfigurableTenantResolver(List claimPaths) {
+ this.claimPaths = List.copyOf(claimPaths);
+ }
+
+ @Override
+ public Mono resolveTenant(SecurityPrincipal principal) {
+ Set tenants = new LinkedHashSet<>();
+ for (String path : claimPaths) {
+ tenants.addAll(ClaimPaths.readStringSet(principal.claims(), path));
+ }
+ return tenants.size() == 1 ? Mono.just(tenants.iterator().next()) : Mono.empty();
+ }
+}
diff --git a/src/test/java/org/fireflyframework/security/core/tenant/ConfigurableTenantResolverTest.java b/src/test/java/org/fireflyframework/security/core/tenant/ConfigurableTenantResolverTest.java
new file mode 100644
index 0000000..35bfeb5
--- /dev/null
+++ b/src/test/java/org/fireflyframework/security/core/tenant/ConfigurableTenantResolverTest.java
@@ -0,0 +1,67 @@
+/*
+ * 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.core.tenant;
+
+import org.fireflyframework.security.api.domain.SecurityPrincipal;
+import org.junit.jupiter.api.Test;
+import reactor.test.StepVerifier;
+
+import java.util.List;
+import java.util.Map;
+
+class ConfigurableTenantResolverTest {
+
+ private final ConfigurableTenantResolver resolver = new ConfigurableTenantResolver(List.of("tenant_ids"));
+
+ private static SecurityPrincipal principalWith(Object tenantClaim) {
+ return SecurityPrincipal.builder()
+ .subject("user-1")
+ .claims(tenantClaim == null ? Map.of() : Map.of("tenant_ids", tenantClaim))
+ .build();
+ }
+
+ @Test
+ void emitsTheSingleTenant() {
+ StepVerifier.create(resolver.resolveTenant(principalWith(List.of("tenant-a"))))
+ .expectNext("tenant-a")
+ .verifyComplete();
+ }
+
+ @Test
+ void emptyWhenNoTenant() {
+ StepVerifier.create(resolver.resolveTenant(principalWith(null)))
+ .verifyComplete();
+ }
+
+ @Test
+ void emptyWhenMoreThanOneTenantFailClosed() {
+ StepVerifier.create(resolver.resolveTenant(principalWith(List.of("tenant-a", "tenant-b"))))
+ .verifyComplete();
+ }
+
+ @Test
+ void readsNestedClaimPath() {
+ ConfigurableTenantResolver nested = new ConfigurableTenantResolver(List.of("org.tenant"));
+ SecurityPrincipal principal = SecurityPrincipal.builder()
+ .subject("user-1")
+ .claims(Map.of("org", Map.of("tenant", "tenant-x")))
+ .build();
+ StepVerifier.create(nested.resolveTenant(principal))
+ .expectNext("tenant-x")
+ .verifyComplete();
+ }
+}