You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hbase.apache.org by pa...@apache.org on 2026/10/20 09:28:01 UTC

(hbase) branch branch-3 updated: HBASE-30387 Support SingleEKU certificates (#8681)

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

PDavid pushed a commit to branch branch-3
in repository https://gitbox.apache.org/repos/asf/hbase.git


The following commit(s) were added to refs/heads/branch-3 by this push:
     new 2dce109e4cf HBASE-30387 Support SingleEKU certificates (#8681)
2dce109e4cf is described below

commit 2dce109e4cfcb86623eee4062867c63d31c6fb4d
Author: Luca Kovács <ko...@gmail.com>
AuthorDate: Mon Oct 19 13:46:27 2026 +0200

    HBASE-30387 Support SingleEKU certificates (#8681)
    
    Signed-off-by: Peter Somogyi <ps...@apache.com>
    Signed-off-by: Dávid Paksy <pa...@apache.org>
    (cherry picked from commit 09474ab77bfa8bede96131164fd4451f093a1a10)
---
 .../apache/hadoop/hbase/ipc/NettyRpcClient.java    |   2 +-
 .../hadoop/hbase/io/crypto/tls/TLSStore.java       | 172 ++++++++++
 .../hadoop/hbase/io/crypto/tls/X509Util.java       | 136 ++++++--
 .../hadoop/hbase/io/TestFileChangeWatcher.java     |   4 +-
 .../hadoop/hbase/io/crypto/tls/TestX509Util.java   | 129 ++++++++
 .../org/apache/hadoop/hbase/http/HttpServer.java   |  22 +-
 .../org/apache/hadoop/hbase/http/InfoServer.java   |  63 +++-
 .../hadoop/hbase/http/TestInfoServerTLSConfig.java |  93 ++++++
 .../org/apache/hadoop/hbase/rest/Constants.java    |  25 ++
 .../org/apache/hadoop/hbase/rest/RESTServer.java   |  49 ++-
 .../hadoop/hbase/rest/TestRESTServerSSL.java       |  93 ++++++
 .../apache/hadoop/hbase/ipc/NettyRpcServer.java    |   2 +-
 .../org/apache/hadoop/hbase/thrift/Constants.java  |  29 ++
 .../apache/hadoop/hbase/thrift/ThriftServer.java   |  72 +++-
 .../thrift/TestThriftServerSSLMutualAuth.java      | 361 +++++++++++++++++++++
 15 files changed, 1192 insertions(+), 60 deletions(-)

diff --git a/hbase-client/src/main/java/org/apache/hadoop/hbase/ipc/NettyRpcClient.java b/hbase-client/src/main/java/org/apache/hadoop/hbase/ipc/NettyRpcClient.java
index ed0c4fffc72..44689d695d9 100644
--- a/hbase-client/src/main/java/org/apache/hadoop/hbase/ipc/NettyRpcClient.java
+++ b/hbase-client/src/main/java/org/apache/hadoop/hbase/ipc/NettyRpcClient.java
@@ -116,7 +116,7 @@ public class NettyRpcClient extends AbstractRpcClient<NettyRpcConnection> {
         keyStoreWatcher.get() == null && trustStoreWatcher.get() == null
           && conf.getBoolean(X509Util.TLS_CERT_RELOAD, false)
       ) {
-        X509Util.enableCertFileReloading(conf, keyStoreWatcher, trustStoreWatcher,
+        X509Util.enableCertFileReloadingForClient(conf, keyStoreWatcher, trustStoreWatcher,
           () -> sslContextForClient.set(null));
       }
     }
diff --git a/hbase-common/src/main/java/org/apache/hadoop/hbase/io/crypto/tls/TLSStore.java b/hbase-common/src/main/java/org/apache/hadoop/hbase/io/crypto/tls/TLSStore.java
new file mode 100644
index 00000000000..696f11408e9
--- /dev/null
+++ b/hbase-common/src/main/java/org/apache/hadoop/hbase/io/crypto/tls/TLSStore.java
@@ -0,0 +1,172 @@
+/*
+ * 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.hadoop.hbase.io.crypto.tls;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.Collectors;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.yetus.audience.InterfaceAudience;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * One keystore or truststore, resolved from configuration with every attribute drawn from a single
+ * key prefix.
+ * <p/>
+ * Each TLS surface exposes two prefixes for the same store: a role-scoped one added for single-EKU
+ * certificate support (e.g. {@code hbase.rpc.tls.server.}) and the historical unscoped one (e.g.
+ * {@code hbase.rpc.tls.}). Resolving attribute by attribute would let a store's location come from
+ * one prefix while its password or type came from the other, which opens either the wrong file or
+ * the right file with the wrong credentials. {@link #resolve} therefore picks the prefix once, from
+ * the location, and reads the rest of the store from that same prefix.
+ */
+@InterfaceAudience.Private
+public final class TLSStore {
+
+  private static final Logger LOG = LoggerFactory.getLogger(TLSStore.class);
+
+  /** Stores already checked for unused legacy keys, so the scan and the warning happen once. */
+  private static final Set<String> LOGGED_STORES = ConcurrentHashMap.newKeySet();
+
+  /**
+   * The key postfixes making up one store, by surface. The RPC keys use {@code .location} while the
+   * servlet surfaces (REST, Thrift) use {@code .store}, and only the latter have a separate
+   * {@code keypassword}.
+   */
+  public enum Keys {
+    RPC_KEYSTORE("keystore.location", "keystore.password", "keystore.type", null),
+    RPC_TRUSTSTORE("truststore.location", "truststore.password", "truststore.type", null),
+    SERVLET_KEYSTORE("keystore.store", "keystore.password", "keystore.type",
+      "keystore.keypassword"),
+    SERVLET_TRUSTSTORE("truststore.store", "truststore.password", "truststore.type", null);
+
+    private final String location;
+    private final String password;
+    private final String type;
+    private final String keyPassword;
+
+    Keys(String location, String password, String type, String keyPassword) {
+      this.location = location;
+      this.password = password;
+      this.type = type;
+      this.keyPassword = keyPassword;
+    }
+
+    private String[] all() {
+      return keyPassword == null
+        ? new String[] { location, password, type }
+        : new String[] { location, password, type, keyPassword };
+    }
+  }
+
+  private final String location;
+  private final char[] password;
+  private final char[] keyPassword;
+  private final String type;
+
+  private TLSStore(String location, char[] password, char[] keyPassword, String type) {
+    this.location = location;
+    this.password = password;
+    this.keyPassword = keyPassword;
+    this.type = type;
+  }
+
+  /**
+   * Reads one store from {@code config}. The prefix supplying the location supplies every other
+   * attribute too, so the two prefixes are never combined within a single store.
+   * <p/>
+   * Only the location is mandatory under a prefix: a store may have no password at all, and the
+   * type is auto-detected from the file extension when absent (see
+   * {@link KeyStoreFileType#fromPropertyValueOrFileName}). Keeping the legacy keys in place while
+   * migrating is valid -- the ones left unused are logged once so they can be cleaned up.
+   * @param config       the configuration to read from
+   * @param rolePrefix   role-scoped prefix, e.g. {@code hbase.rpc.tls.server.}
+   * @param legacyPrefix historical unscoped prefix, e.g. {@code hbase.rpc.tls.}
+   * @param keys         which store to read, and under which postfixes
+   * @throws IllegalArgumentException if {@code rolePrefix} supplies any attribute but not the
+   *                                  location, since the location would then be taken from
+   *                                  {@code legacyPrefix}
+   */
+  public static TLSStore resolve(Configuration config, String rolePrefix, String legacyPrefix,
+    Keys keys) throws IOException {
+    String roleLocationKey = rolePrefix + keys.location;
+    boolean useRole = config.get(roleLocationKey) != null;
+    if (!useRole) {
+      for (String postfix : keys.all()) {
+        if (config.get(rolePrefix + postfix) != null) {
+          throw new IllegalArgumentException(
+            rolePrefix + postfix + " is set, but " + roleLocationKey + " is not. Once any "
+              + rolePrefix + " key is used, that prefix must supply the store location.");
+        }
+      }
+    }
+    String prefix = useRole ? rolePrefix : legacyPrefix;
+    if (useRole) {
+      logOnce(config, rolePrefix, legacyPrefix, keys);
+    }
+    return new TLSStore(config.get(prefix + keys.location, ""),
+      config.getPassword(prefix + keys.password),
+      keys.keyPassword == null ? null : config.getPassword(prefix + keys.keyPassword),
+      config.get(prefix + keys.type, ""));
+  }
+
+  private static void logOnce(Configuration config, String rolePrefix, String legacyPrefix,
+    Keys keys) {
+    if (!LOGGED_STORES.add(rolePrefix + keys.location)) {
+      return;
+    }
+    List<String> unused = Arrays.stream(keys.all()).map(postfix -> legacyPrefix + postfix)
+      .filter(key -> config.get(key) != null).collect(Collectors.toList());
+    if (!unused.isEmpty()) {
+      LOG.warn("{} supplies this store, so these keys are unused: {}. Remove them once the"
+        + " migration is complete.", rolePrefix, String.join(", ", unused));
+    }
+  }
+
+  public String getLocation() {
+    return location;
+  }
+
+  public String getPassword() {
+    return password == null ? null : new String(password);
+  }
+
+  /** Falls back to the store password when no separate key password is configured. */
+  public String getKeyPassword() {
+    char[] effective = getKeyPasswordChars();
+    return effective == null ? null : new String(effective);
+  }
+
+  /** For the netty paths, whose key/trust manager factories take char[]. */
+  public char[] getPasswordChars() {
+    return password;
+  }
+
+  /** @see #getKeyPassword() */
+  public char[] getKeyPasswordChars() {
+    return keyPassword != null ? keyPassword : password;
+  }
+
+  public String getType() {
+    return type;
+  }
+}
diff --git a/hbase-common/src/main/java/org/apache/hadoop/hbase/io/crypto/tls/X509Util.java b/hbase-common/src/main/java/org/apache/hadoop/hbase/io/crypto/tls/X509Util.java
index d6be0eed844..d321f1d7a9e 100644
--- a/hbase-common/src/main/java/org/apache/hadoop/hbase/io/crypto/tls/X509Util.java
+++ b/hbase-common/src/main/java/org/apache/hadoop/hbase/io/crypto/tls/X509Util.java
@@ -37,6 +37,7 @@ import javax.net.ssl.TrustManagerFactory;
 import javax.net.ssl.X509ExtendedTrustManager;
 import javax.net.ssl.X509KeyManager;
 import javax.net.ssl.X509TrustManager;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.hadoop.conf.Configuration;
 import org.apache.hadoop.hbase.exceptions.KeyManagerException;
 import org.apache.hadoop.hbase.exceptions.SSLContextException;
@@ -89,6 +90,43 @@ public final class X509Util {
   public static final String TLS_CERT_RELOAD = CONFIG_PREFIX + "certReload";
   public static final String TLS_USE_OPENSSL = CONFIG_PREFIX + "useOpenSsl";
 
+  //
+  // Role-scoped keystore/truststore configs for single-EKU certificate support.
+  //
+
+  /**
+   * When set, these take precedence over the unscoped keys above; when unset, the unscoped keys are
+   * used as a fallback so existing deployments keep working unchanged.
+   */
+  static final String CLIENT_CONFIG_PREFIX = CONFIG_PREFIX + "client.";
+  static final String SERVER_CONFIG_PREFIX = CONFIG_PREFIX + "server.";
+
+  public static final String TLS_CONFIG_CLIENT_KEYSTORE_LOCATION =
+    CLIENT_CONFIG_PREFIX + "keystore.location";
+  public static final String TLS_CONFIG_CLIENT_KEYSTORE_TYPE =
+    CLIENT_CONFIG_PREFIX + "keystore.type";
+  public static final String TLS_CONFIG_CLIENT_KEYSTORE_PASSWORD =
+    CLIENT_CONFIG_PREFIX + "keystore.password";
+  public static final String TLS_CONFIG_CLIENT_TRUSTSTORE_LOCATION =
+    CLIENT_CONFIG_PREFIX + "truststore.location";
+  public static final String TLS_CONFIG_CLIENT_TRUSTSTORE_TYPE =
+    CLIENT_CONFIG_PREFIX + "truststore.type";
+  public static final String TLS_CONFIG_CLIENT_TRUSTSTORE_PASSWORD =
+    CLIENT_CONFIG_PREFIX + "truststore.password";
+
+  public static final String TLS_CONFIG_SERVER_KEYSTORE_LOCATION =
+    SERVER_CONFIG_PREFIX + "keystore.location";
+  public static final String TLS_CONFIG_SERVER_KEYSTORE_TYPE =
+    SERVER_CONFIG_PREFIX + "keystore.type";
+  public static final String TLS_CONFIG_SERVER_KEYSTORE_PASSWORD =
+    SERVER_CONFIG_PREFIX + "keystore.password";
+  public static final String TLS_CONFIG_SERVER_TRUSTSTORE_LOCATION =
+    SERVER_CONFIG_PREFIX + "truststore.location";
+  public static final String TLS_CONFIG_SERVER_TRUSTSTORE_TYPE =
+    SERVER_CONFIG_PREFIX + "truststore.type";
+  public static final String TLS_CONFIG_SERVER_TRUSTSTORE_PASSWORD =
+    SERVER_CONFIG_PREFIX + "truststore.password";
+
   //
   // Server-side specific configs
   //
@@ -165,6 +203,20 @@ public final class X509Util {
     }
   }
 
+  // Jetty never leaves the peer-certificate trust anchors empty: with no truststore configured it
+  // falls back to the keystore, or to the JVM cacerts when no keystore is set either. WANT/NEED
+  // would then appear to work while trusting issuers the operator never configured, so fail fast.
+  public static void validateClientAuthTrustStore(ClientAuth clientAuth, String trustStoreLocation,
+    String clientAuthModeKey, String... trustStoreLocationKeys) {
+    if (clientAuth == ClientAuth.NONE || StringUtils.isNotBlank(trustStoreLocation)) {
+      return;
+    }
+    throw new IllegalArgumentException(clientAuthModeKey + "=" + clientAuth
+      + " requires a client-certificate trust root, but no truststore is configured."
+      + " Configure a truststore via [" + String.join(", ", trustStoreLocationKeys)
+      + "], or disable client certificate checking with " + clientAuthModeKey + "=NONE.");
+  }
+
   private X509Util() {
     // disabled
   }
@@ -175,20 +227,25 @@ public final class X509Util {
     SslContextBuilder sslContextBuilder = SslContextBuilder.forClient();
 
     configureOpenSslIfAvailable(sslContextBuilder, config);
-    String keyStoreLocation = config.get(TLS_CONFIG_KEYSTORE_LOCATION, "");
-    char[] keyStorePassword = config.getPassword(TLS_CONFIG_KEYSTORE_PASSWORD);
-    String keyStoreType = config.get(TLS_CONFIG_KEYSTORE_TYPE, "");
+    TLSStore keyStore =
+      TLSStore.resolve(config, CLIENT_CONFIG_PREFIX, CONFIG_PREFIX, TLSStore.Keys.RPC_KEYSTORE);
+    String keyStoreLocation = keyStore.getLocation();
+    char[] keyStorePassword = keyStore.getPasswordChars();
+    String keyStoreType = keyStore.getType();
 
     if (keyStoreLocation.isEmpty()) {
-      LOG.warn(TLS_CONFIG_KEYSTORE_LOCATION + " not specified");
+      LOG.warn("Neither {} nor {} specified", TLS_CONFIG_CLIENT_KEYSTORE_LOCATION,
+        TLS_CONFIG_KEYSTORE_LOCATION);
     } else {
       sslContextBuilder
         .keyManager(createKeyManager(keyStoreLocation, keyStorePassword, keyStoreType));
     }
 
-    String trustStoreLocation = config.get(TLS_CONFIG_TRUSTSTORE_LOCATION, "");
-    char[] trustStorePassword = config.getPassword(TLS_CONFIG_TRUSTSTORE_PASSWORD);
-    String trustStoreType = config.get(TLS_CONFIG_TRUSTSTORE_TYPE, "");
+    TLSStore trustStore =
+      TLSStore.resolve(config, CLIENT_CONFIG_PREFIX, CONFIG_PREFIX, TLSStore.Keys.RPC_TRUSTSTORE);
+    String trustStoreLocation = trustStore.getLocation();
+    char[] trustStorePassword = trustStore.getPasswordChars();
+    String trustStoreType = trustStore.getType();
 
     boolean sslCrlEnabled = config.getBoolean(TLS_CONFIG_CLR, false);
     boolean sslOcspEnabled = config.getBoolean(TLS_CONFIG_OCSP, false);
@@ -198,7 +255,8 @@ public final class X509Util {
     boolean allowReverseDnsLookup = config.getBoolean(TLS_CONFIG_REVERSE_DNS_LOOKUP_ENABLED, true);
 
     if (trustStoreLocation.isEmpty()) {
-      LOG.warn(TLS_CONFIG_TRUSTSTORE_LOCATION + " not specified");
+      LOG.warn("Neither {} nor {} specified", TLS_CONFIG_CLIENT_TRUSTSTORE_LOCATION,
+        TLS_CONFIG_TRUSTSTORE_LOCATION);
     } else {
       sslContextBuilder
         .trustManager(createTrustManager(trustStoreLocation, trustStorePassword, trustStoreType,
@@ -249,13 +307,15 @@ public final class X509Util {
 
   public static SslContext createSslContextForServer(Configuration config)
     throws X509Exception, IOException {
-    String keyStoreLocation = config.get(TLS_CONFIG_KEYSTORE_LOCATION, "");
-    char[] keyStorePassword = config.getPassword(TLS_CONFIG_KEYSTORE_PASSWORD);
-    String keyStoreType = config.get(TLS_CONFIG_KEYSTORE_TYPE, "");
+    TLSStore keyStore =
+      TLSStore.resolve(config, SERVER_CONFIG_PREFIX, CONFIG_PREFIX, TLSStore.Keys.RPC_KEYSTORE);
+    String keyStoreLocation = keyStore.getLocation();
+    char[] keyStorePassword = keyStore.getPasswordChars();
+    String keyStoreType = keyStore.getType();
 
     if (keyStoreLocation.isEmpty()) {
-      throw new SSLContextException(
-        "Keystore is required for SSL server: " + TLS_CONFIG_KEYSTORE_LOCATION);
+      throw new SSLContextException("Keystore is required for SSL server: set either "
+        + TLS_CONFIG_SERVER_KEYSTORE_LOCATION + " or " + TLS_CONFIG_KEYSTORE_LOCATION);
     }
 
     SslContextBuilder sslContextBuilder;
@@ -263,9 +323,11 @@ public final class X509Util {
       .forServer(createKeyManager(keyStoreLocation, keyStorePassword, keyStoreType));
 
     configureOpenSslIfAvailable(sslContextBuilder, config);
-    String trustStoreLocation = config.get(TLS_CONFIG_TRUSTSTORE_LOCATION, "");
-    char[] trustStorePassword = config.getPassword(TLS_CONFIG_TRUSTSTORE_PASSWORD);
-    String trustStoreType = config.get(TLS_CONFIG_TRUSTSTORE_TYPE, "");
+    TLSStore trustStore =
+      TLSStore.resolve(config, SERVER_CONFIG_PREFIX, CONFIG_PREFIX, TLSStore.Keys.RPC_TRUSTSTORE);
+    String trustStoreLocation = trustStore.getLocation();
+    char[] trustStorePassword = trustStore.getPasswordChars();
+    String trustStoreType = trustStore.getType();
 
     boolean sslCrlEnabled = config.getBoolean(TLS_CONFIG_CLR, false);
     boolean sslOcspEnabled = config.getBoolean(TLS_CONFIG_OCSP, false);
@@ -277,7 +339,8 @@ public final class X509Util {
     boolean allowReverseDnsLookup = config.getBoolean(TLS_CONFIG_REVERSE_DNS_LOOKUP_ENABLED, true);
 
     if (trustStoreLocation.isEmpty()) {
-      LOG.warn(TLS_CONFIG_TRUSTSTORE_LOCATION + " not specified");
+      LOG.warn("Neither {} nor {} specified", TLS_CONFIG_SERVER_TRUSTSTORE_LOCATION,
+        TLS_CONFIG_TRUSTSTORE_LOCATION);
     } else {
       sslContextBuilder
         .trustManager(createTrustManager(trustStoreLocation, trustStorePassword, trustStoreType,
@@ -422,20 +485,47 @@ public final class X509Util {
   }
 
   /**
-   * Enable certificate file reloading by creating FileWatchers for keystore and truststore.
-   * AtomicReferences will be set with the new instances. resetContext - if not null - will be
-   * called when the file has been modified.
+   * Enable certificate file reloading for the RPC <em>client</em> side by creating FileWatchers for
+   * the keystore and truststore whose paths are resolved under {@link #CLIENT_CONFIG_PREFIX}
+   * (role-scoped keys first, legacy keys as fallback). AtomicReferences will be set with the new
+   * instances. {@code resetContext} - if not null - will be called when the file has been modified.
    * @param keystoreWatcher   Reference to keystoreFileWatcher.
    * @param trustStoreWatcher Reference to truststoreFileWatcher.
    * @param resetContext      Callback for file changes.
    */
-  public static void enableCertFileReloading(Configuration config,
+  public static void enableCertFileReloadingForClient(Configuration config,
+    AtomicReference<FileChangeWatcher> keystoreWatcher,
+    AtomicReference<FileChangeWatcher> trustStoreWatcher, Runnable resetContext)
+    throws IOException {
+    enableCertFileReloading(config, CLIENT_CONFIG_PREFIX, keystoreWatcher, trustStoreWatcher,
+      resetContext);
+  }
+
+  /**
+   * Enable certificate file reloading for the RPC <em>server</em> side. See
+   * {@link #enableCertFileReloadingForClient} for parameter semantics; the only difference is that
+   * paths are resolved under {@link #SERVER_CONFIG_PREFIX}, falling back to the legacy unscoped
+   * keys when unset.
+   */
+  public static void enableCertFileReloadingForServer(Configuration config,
+    AtomicReference<FileChangeWatcher> keystoreWatcher,
+    AtomicReference<FileChangeWatcher> trustStoreWatcher, Runnable resetContext)
+    throws IOException {
+    enableCertFileReloading(config, SERVER_CONFIG_PREFIX, keystoreWatcher, trustStoreWatcher,
+      resetContext);
+  }
+
+  private static void enableCertFileReloading(Configuration config, String rolePrefix,
     AtomicReference<FileChangeWatcher> keystoreWatcher,
     AtomicReference<FileChangeWatcher> trustStoreWatcher, Runnable resetContext)
     throws IOException {
-    String keyStoreLocation = config.get(TLS_CONFIG_KEYSTORE_LOCATION, "");
+    // Resolve the watched paths through TLSStore so the file we watch is always the one the
+    // SslContext loaded: the prefix (role-scoped or legacy) is chosen once, from the location.
+    String keyStoreLocation =
+      TLSStore.resolve(config, rolePrefix, CONFIG_PREFIX, TLSStore.Keys.RPC_KEYSTORE).getLocation();
+    String trustStoreLocation = TLSStore
+      .resolve(config, rolePrefix, CONFIG_PREFIX, TLSStore.Keys.RPC_TRUSTSTORE).getLocation();
     keystoreWatcher.set(newFileChangeWatcher(config, keyStoreLocation, resetContext));
-    String trustStoreLocation = config.get(TLS_CONFIG_TRUSTSTORE_LOCATION, "");
     // we are using the same callback for both. there's no reason to kick off two
     // threads if keystore/truststore are both at the same location
     if (!keyStoreLocation.equals(trustStoreLocation)) {
diff --git a/hbase-common/src/test/java/org/apache/hadoop/hbase/io/TestFileChangeWatcher.java b/hbase-common/src/test/java/org/apache/hadoop/hbase/io/TestFileChangeWatcher.java
index 4b822c9d402..48f74cf5780 100644
--- a/hbase-common/src/test/java/org/apache/hadoop/hbase/io/TestFileChangeWatcher.java
+++ b/hbase-common/src/test/java/org/apache/hadoop/hbase/io/TestFileChangeWatcher.java
@@ -111,7 +111,7 @@ public class TestFileChangeWatcher {
     myConf.set(X509Util.TLS_CONFIG_TRUSTSTORE_LOCATION, sharedPath);
     AtomicReference<FileChangeWatcher> keystoreWatcher = new AtomicReference<>();
     AtomicReference<FileChangeWatcher> truststoreWatcher = new AtomicReference<>();
-    X509Util.enableCertFileReloading(myConf, keystoreWatcher, truststoreWatcher, () -> {
+    X509Util.enableCertFileReloadingForServer(myConf, keystoreWatcher, truststoreWatcher, () -> {
     });
     assertNotNull(keystoreWatcher.get());
     assertThat(keystoreWatcher.get().getWatcherThread().getName(), endsWith("foo.jks"));
@@ -122,7 +122,7 @@ public class TestFileChangeWatcher {
 
     String truststorePath = File.createTempFile("bar", "bar.jks").getAbsolutePath();
     myConf.set(X509Util.TLS_CONFIG_TRUSTSTORE_LOCATION, truststorePath);
-    X509Util.enableCertFileReloading(myConf, keystoreWatcher, truststoreWatcher, () -> {
+    X509Util.enableCertFileReloadingForServer(myConf, keystoreWatcher, truststoreWatcher, () -> {
     });
 
     assertNotNull(keystoreWatcher.get());
diff --git a/hbase-common/src/test/java/org/apache/hadoop/hbase/io/crypto/tls/TestX509Util.java b/hbase-common/src/test/java/org/apache/hadoop/hbase/io/crypto/tls/TestX509Util.java
index f2499fc24d7..d715f6bbdb9 100644
--- a/hbase-common/src/test/java/org/apache/hadoop/hbase/io/crypto/tls/TestX509Util.java
+++ b/hbase-common/src/test/java/org/apache/hadoop/hbase/io/crypto/tls/TestX509Util.java
@@ -20,6 +20,7 @@ package org.apache.hadoop.hbase.io.crypto.tls;
 import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assumptions.assumeTrue;
@@ -28,6 +29,7 @@ import static org.mockito.Mockito.mock;
 import java.security.Security;
 import java.util.Arrays;
 import java.util.Collections;
+import org.apache.hadoop.conf.Configuration;
 import org.apache.hadoop.hbase.HBaseParameterizedTestTemplate;
 import org.apache.hadoop.hbase.exceptions.KeyManagerException;
 import org.apache.hadoop.hbase.exceptions.SSLContextException;
@@ -375,4 +377,131 @@ public class TestX509Util extends AbstractTestX509Parameterized {
     });
   }
 
+  // ---------------------------------------------------------------------------
+  // Role-scoped configuration resolution (single-EKU certificate support)
+  // ---------------------------------------------------------------------------
+
+  @TestTemplate
+  public void testCreateSSLContextForClientUsesRoleScopedKeystoreWhenSet() throws Exception {
+    // Move the legacy keystore values to the client-scoped keys and clear the legacy keys, so
+    // that a successful context build proves the client-scoped keys were consulted.
+    String location = conf.get(X509Util.TLS_CONFIG_KEYSTORE_LOCATION);
+    String password = conf.get(X509Util.TLS_CONFIG_KEYSTORE_PASSWORD);
+    String type = conf.get(X509Util.TLS_CONFIG_KEYSTORE_TYPE);
+    conf.set(X509Util.TLS_CONFIG_CLIENT_KEYSTORE_LOCATION, location);
+    conf.set(X509Util.TLS_CONFIG_CLIENT_KEYSTORE_PASSWORD, password);
+    conf.set(X509Util.TLS_CONFIG_CLIENT_KEYSTORE_TYPE, type);
+    conf.unset(X509Util.TLS_CONFIG_KEYSTORE_LOCATION);
+    conf.unset(X509Util.TLS_CONFIG_KEYSTORE_PASSWORD);
+    conf.unset(X509Util.TLS_CONFIG_KEYSTORE_TYPE);
+
+    SslContext sslContext = X509Util.createSslContextForClient(conf);
+    ByteBufAllocator byteBufAllocatorMock = mock(ByteBufAllocator.class);
+    // Handshake would fail if the key manager weren't wired; smoke-test that engine creation works.
+    assertTrue(
+      sslContext.newEngine(byteBufAllocatorMock).getSSLParameters().getProtocols().length > 0);
+  }
+
+  @TestTemplate
+  public void testCreateSSLContextForServerUsesRoleScopedKeystoreWhenSet() throws Exception {
+    String location = conf.get(X509Util.TLS_CONFIG_KEYSTORE_LOCATION);
+    String password = conf.get(X509Util.TLS_CONFIG_KEYSTORE_PASSWORD);
+    String type = conf.get(X509Util.TLS_CONFIG_KEYSTORE_TYPE);
+    conf.set(X509Util.TLS_CONFIG_SERVER_KEYSTORE_LOCATION, location);
+    conf.set(X509Util.TLS_CONFIG_SERVER_KEYSTORE_PASSWORD, password);
+    conf.set(X509Util.TLS_CONFIG_SERVER_KEYSTORE_TYPE, type);
+    conf.unset(X509Util.TLS_CONFIG_KEYSTORE_LOCATION);
+    conf.unset(X509Util.TLS_CONFIG_KEYSTORE_PASSWORD);
+    conf.unset(X509Util.TLS_CONFIG_KEYSTORE_TYPE);
+
+    SslContext sslContext = X509Util.createSslContextForServer(conf);
+    ByteBufAllocator byteBufAllocatorMock = mock(ByteBufAllocator.class);
+    assertTrue(
+      sslContext.newEngine(byteBufAllocatorMock).getSSLParameters().getProtocols().length > 0);
+  }
+
+  @TestTemplate
+  public void testCreateSSLContextForServerThrowsWhenNeitherKeystoreSet() {
+    conf.unset(X509Util.TLS_CONFIG_KEYSTORE_LOCATION);
+    conf.unset(X509Util.TLS_CONFIG_SERVER_KEYSTORE_LOCATION);
+    SSLContextException ex =
+      assertThrows(SSLContextException.class, () -> X509Util.createSslContextForServer(conf));
+    // The error should name both keys so the operator knows what to set.
+    assertTrue(ex.getMessage().contains(X509Util.TLS_CONFIG_SERVER_KEYSTORE_LOCATION),
+      "message should mention role-scoped key, got: " + ex.getMessage());
+    assertTrue(ex.getMessage().contains(X509Util.TLS_CONFIG_KEYSTORE_LOCATION),
+      "message should mention legacy key, got: " + ex.getMessage());
+  }
+
+  @TestTemplate
+  public void testValidateClientAuthTrustStoreRejectsNeedWithoutTrustStore() {
+    IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
+      () -> X509Util.validateClientAuthTrustStore(X509Util.ClientAuth.NEED, null, "the.mode.key",
+        "the.truststore.key"));
+    assertTrue(ex.getMessage().contains("the.mode.key"), ex.getMessage());
+    assertTrue(ex.getMessage().contains("the.truststore.key"), ex.getMessage());
+  }
+
+  @TestTemplate
+  public void testValidateClientAuthTrustStoreAllowsNoneWithoutTrustStore() {
+    // NONE never requests a peer certificate, so a missing truststore is not a misconfiguration.
+    X509Util.validateClientAuthTrustStore(X509Util.ClientAuth.NONE, null, "mode.key", "ts.key");
+  }
+
+  // A role-scoped password with no role-scoped location would be applied to the legacy keystore.
+  @TestTemplate
+  public void testCreateSSLContextForClientRejectsRoleKeyWithoutRoleLocation() {
+    // The base conf carries a complete legacy keystore, so only the role-scoped location is
+    // missing.
+    conf.set(X509Util.TLS_CONFIG_CLIENT_KEYSTORE_PASSWORD, "orphan-pw");
+    IllegalArgumentException ex =
+      assertThrows(IllegalArgumentException.class, () -> X509Util.createSslContextForClient(conf));
+    assertTrue(ex.getMessage().contains(X509Util.TLS_CONFIG_CLIENT_KEYSTORE_PASSWORD),
+      ex.getMessage());
+    assertTrue(ex.getMessage().contains(X509Util.TLS_CONFIG_CLIENT_KEYSTORE_LOCATION),
+      ex.getMessage());
+  }
+
+  @TestTemplate
+  public void testResolveStoreRejectsRoleKeyWithoutRoleLocation() {
+    Configuration c = new Configuration(false);
+    c.set("p.role.keystore.password", "pw2");
+    c.set("p.keystore.location", "/old.jks");
+    IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
+      () -> TLSStore.resolve(c, "p.role.", "p.", TLSStore.Keys.RPC_KEYSTORE));
+    assertTrue(ex.getMessage().contains("p.role.keystore.password"), ex.getMessage());
+    assertTrue(ex.getMessage().contains("p.role.keystore.location"), ex.getMessage());
+  }
+
+  /**
+   * A store is a unit: the prefix that supplies the location supplies every other attribute too.
+   * Resolving per key would open the role-scoped file with the legacy password and type.
+   */
+  @TestTemplate
+  public void testResolveStoreNeverMixesPrefixes() throws Exception {
+    Configuration c = new Configuration(false);
+    c.set("p.role.keystore.location", "/new.p12");
+    c.set("p.keystore.location", "/old.jks");
+    c.set("p.keystore.password", "legacy-pw");
+    c.set("p.keystore.type", "JKS");
+
+    TLSStore store = TLSStore.resolve(c, "p.role.", "p.", TLSStore.Keys.RPC_KEYSTORE);
+    assertEquals("/new.p12", store.getLocation());
+    assertNull(store.getPassword(), "password must not be taken from the legacy prefix");
+    assertEquals("", store.getType(), "type must not be taken from the legacy prefix");
+  }
+
+  // Deployments that never adopt the role-scoped prefix keep resolving from the legacy keys.
+  @TestTemplate
+  public void testResolveStoreFallsBackToLegacyPrefix() throws Exception {
+    Configuration c = new Configuration(false);
+    c.set("p.keystore.location", "/old.jks");
+    c.set("p.keystore.password", "pw");
+    c.set("p.keystore.type", "JKS");
+
+    TLSStore store = TLSStore.resolve(c, "p.role.", "p.", TLSStore.Keys.RPC_KEYSTORE);
+    assertEquals("/old.jks", store.getLocation());
+    assertEquals("pw", store.getPassword());
+    assertEquals("JKS", store.getType());
+  }
 }
diff --git a/hbase-http/src/main/java/org/apache/hadoop/hbase/http/HttpServer.java b/hbase-http/src/main/java/org/apache/hadoop/hbase/http/HttpServer.java
index 49af0939d5a..7019f530610 100644
--- a/hbase-http/src/main/java/org/apache/hadoop/hbase/http/HttpServer.java
+++ b/hbase-http/src/main/java/org/apache/hadoop/hbase/http/HttpServer.java
@@ -227,6 +227,7 @@ public class HttpServer implements FilterContainer {
     private String usernameConfKey;
     private String keytabConfKey;
     private boolean needsClientAuth;
+    private boolean wantsClientAuth;
     private String includeCiphers;
     private String excludeCiphers;
     private String includeProtocols;
@@ -311,13 +312,31 @@ public class HttpServer implements FilterContainer {
     }
 
     /**
-     * Specify whether the server should authorize the client in SSL connections.
+     * Specify whether the server should require a client certificate during the SSL handshake
+     * (mTLS). When true, clients that do not present a valid certificate are rejected.
+     * <p>
+     * Takes precedence over {@link #wantsClientAuth(boolean)} in Jetty when both are set.
+     * <p>
+     * Callers enabling either mode must also configure a truststore, since Jetty otherwise silently
+     * falls back to the keystore or the JVM cacerts as the client-cert trust anchor. See
+     * {@link org.apache.hadoop.hbase.io.crypto.tls.X509Util#validateClientAuthTrustStore}.
      */
     public Builder needsClientAuth(boolean value) {
       this.needsClientAuth = value;
       return this;
     }
 
+    /**
+     * Specify whether the server should request a client certificate during the SSL handshake but
+     * still accept clients that do not present one. Weaker than {@link #needsClientAuth(boolean)}:
+     * use this to signal "opportunistic mTLS" where a client cert is validated when supplied but
+     * its absence is tolerated.
+     */
+    public Builder wantsClientAuth(boolean value) {
+      this.wantsClientAuth = value;
+      return this;
+    }
+
     /**
      * @see #setAppDir(String)
      * @deprecated Since 0.99.0. Use {@link #setAppDir(String)} instead.
@@ -476,6 +495,7 @@ public class HttpServer implements FilterContainer {
           httpsConfig.addCustomizer(new SecureRequestCustomizer());
           SslContextFactory.Server sslCtxFactory = new SslContextFactory.Server();
           sslCtxFactory.setNeedClientAuth(needsClientAuth);
+          sslCtxFactory.setWantClientAuth(wantsClientAuth);
           sslCtxFactory.setKeyManagerPassword(keyPassword);
 
           if (keyStore != null) {
diff --git a/hbase-http/src/main/java/org/apache/hadoop/hbase/http/InfoServer.java b/hbase-http/src/main/java/org/apache/hadoop/hbase/http/InfoServer.java
index 5a09315ed77..99cbd5a1652 100644
--- a/hbase-http/src/main/java/org/apache/hadoop/hbase/http/InfoServer.java
+++ b/hbase-http/src/main/java/org/apache/hadoop/hbase/http/InfoServer.java
@@ -22,9 +22,11 @@ import java.net.URI;
 import javax.servlet.ServletContext;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.hadoop.conf.Configuration;
 import org.apache.hadoop.fs.CommonConfigurationKeys;
 import org.apache.hadoop.hbase.HBaseConfiguration;
+import org.apache.hadoop.hbase.io.crypto.tls.X509Util;
 import org.apache.hadoop.security.authorize.AccessControlList;
 import org.apache.yetus.audience.InterfaceAudience;
 
@@ -44,6 +46,17 @@ public class InfoServer {
 
   private static final String HADOOP_WEB_TLS_CONFIG_PREFIX = "ssl.server.";
   private static final String HBASE_WEB_TLS_CONFIG_PREFIX = "hbase.ui.ssl.";
+  // Role-scoped prefix for single-EKU certificate support. When set, takes precedence over both
+  // HBASE_WEB_TLS_CONFIG_PREFIX and HADOOP_WEB_TLS_CONFIG_PREFIX. The UI process only ever plays
+  // the TLS-server role, so no parallel .client. prefix is defined.
+  private static final String HBASE_WEB_TLS_SERVER_CONFIG_PREFIX = "hbase.ui.ssl.server.";
+  private static final String TRUSTSTORE_LOCATION = "truststore.location";
+  /**
+   * Config key controlling whether the UI's TLS connector requests or requires a client
+   * certificate. Valid values: {@code NONE} (default), {@code WANT}, {@code NEED}.
+   */
+  static final String HBASE_UI_SSL_CLIENT_AUTH_MODE =
+    HBASE_WEB_TLS_SERVER_CONFIG_PREFIX + "client.auth.mode";
 
   /**
    * Create a status server on the given port. The jsp scripts are taken from
@@ -73,16 +86,35 @@ public class InfoServer {
       // We are using the Hadoop HTTP server config properties.
       // This makes it easy to keep in sync with Hadoop's UI servers, but hard to set this
       // separately for HBase.
+      String trustStoreLocation = getTLSProperty(c, TRUSTSTORE_LOCATION);
+
       builder.keyPassword(getTLSPassword(c, "keystore.keypassword"))
         .keyStore(getTLSProperty(c, "keystore.location"), getTLSPassword(c, "keystore.password"),
           getTLSProperty(c, "keystore.type", "jks"))
-        .trustStore(getTLSProperty(c, "truststore.location"),
-          getTLSPassword(c, "truststore.password"), getTLSProperty(c, "truststore.type", "jks"))
+        .trustStore(trustStoreLocation, getTLSPassword(c, "truststore.password"),
+          getTLSProperty(c, "truststore.type", "jks"))
         // The ssl.server.*.protocols properties do not exist in Hadoop at the time of writing.
         .setIncludeProtocols(getTLSProperty(c, "include.protocols"))
         .setExcludeProtocols(getTLSProperty(c, "exclude.protocols"))
         .setIncludeCiphers(getTLSProperty(c, "include.cipher.list"))
         .setExcludeCiphers(getTLSProperty(c, "exclude.cipher.list"));
+
+      // Activate mutual TLS if configured. Default is NONE, which preserves today's behavior of
+      // never requesting a client certificate on the UI connector (leaving any configured
+      // truststore inert for peer verification). Set hbase.ui.ssl.server.client.auth.mode to
+      // WANT or NEED to opt in. The client.auth.mode key is looked up directly on
+      // HBASE_WEB_TLS_SERVER_CONFIG_PREFIX; there is no legacy or Hadoop-prefixed fallback.
+      // getTrimmed + defaultIfBlank so that an empty or whitespace-only <value></value> in
+      // hbase-site.xml behaves like an absent key. A bare fromPropertyValue("") would return NEED.
+      X509Util.ClientAuth clientAuth =
+        X509Util.ClientAuth.fromPropertyValue(StringUtils.defaultIfBlank(
+          c.getTrimmed(HBASE_UI_SSL_CLIENT_AUTH_MODE), X509Util.ClientAuth.NONE.name()));
+      X509Util.validateClientAuthTrustStore(clientAuth, trustStoreLocation,
+        HBASE_UI_SSL_CLIENT_AUTH_MODE, HBASE_WEB_TLS_SERVER_CONFIG_PREFIX + TRUSTSTORE_LOCATION,
+        HBASE_WEB_TLS_CONFIG_PREFIX + TRUSTSTORE_LOCATION,
+        HADOOP_WEB_TLS_CONFIG_PREFIX + TRUSTSTORE_LOCATION);
+      builder.needsClientAuth(clientAuth == X509Util.ClientAuth.NEED)
+        .wantsClientAuth(clientAuth == X509Util.ClientAuth.WANT);
     }
 
     final String httpAuthType = c.get(HttpServer.HTTP_UI_AUTHENTICATION, "").toLowerCase();
@@ -104,18 +136,31 @@ public class InfoServer {
     this.httpServer = builder.build();
   }
 
-  private String getTLSPassword(Configuration c, String postfix) throws IOException {
-    return HBaseConfiguration.getPassword(c, HBASE_WEB_TLS_CONFIG_PREFIX + postfix,
-      HBaseConfiguration.getPassword(c, HADOOP_WEB_TLS_CONFIG_PREFIX + postfix, null));
+  /**
+   * Resolves a TLS-related password with a 3-tier fallback: the role-scoped
+   * {@code hbase.ui.ssl.server.<postfix>} key is checked first, then the unscoped
+   * {@code hbase.ui.ssl.<postfix>} key, and finally Hadoop's {@code ssl.server.<postfix>}.
+   * Package-private for direct testing.
+   */
+  static String getTLSPassword(Configuration c, String postfix) throws IOException {
+    return HBaseConfiguration.getPassword(c, HBASE_WEB_TLS_SERVER_CONFIG_PREFIX + postfix,
+      HBaseConfiguration.getPassword(c, HBASE_WEB_TLS_CONFIG_PREFIX + postfix,
+        HBaseConfiguration.getPassword(c, HADOOP_WEB_TLS_CONFIG_PREFIX + postfix, null)));
   }
 
-  private String getTLSProperty(Configuration c, String postfix) {
+  static String getTLSProperty(Configuration c, String postfix) {
     return getTLSProperty(c, postfix, null);
   }
 
-  private String getTLSProperty(Configuration c, String postfix, String defaultValue) {
-    return c.get(HBASE_WEB_TLS_CONFIG_PREFIX + postfix,
-      c.get(HADOOP_WEB_TLS_CONFIG_PREFIX + postfix, defaultValue));
+  /**
+   * Resolves a TLS-related property with a 3-tier fallback: role-scoped
+   * {@code hbase.ui.ssl.server.<postfix>} → {@code hbase.ui.ssl.<postfix>} →
+   * {@code ssl.server.<postfix>} → {@code defaultValue}. Package-private for direct testing.
+   */
+  static String getTLSProperty(Configuration c, String postfix, String defaultValue) {
+    return c.get(HBASE_WEB_TLS_SERVER_CONFIG_PREFIX + postfix,
+      c.get(HBASE_WEB_TLS_CONFIG_PREFIX + postfix,
+        c.get(HADOOP_WEB_TLS_CONFIG_PREFIX + postfix, defaultValue)));
   }
 
   /**
diff --git a/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestInfoServerTLSConfig.java b/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestInfoServerTLSConfig.java
new file mode 100644
index 00000000000..8b6bd36de45
--- /dev/null
+++ b/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestInfoServerTLSConfig.java
@@ -0,0 +1,93 @@
+/*
+ * 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.hadoop.hbase.http;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hbase.testclassification.MiscTests;
+import org.apache.hadoop.hbase.testclassification.SmallTests;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for {@link InfoServer}'s TLS-config resolution: the 3-tier fallback chain
+ * ({@code hbase.ui.ssl.server.*} → {@code hbase.ui.ssl.*} → {@code ssl.server.*}) that underpins
+ * single-EKU certificate support on the UI surface.
+ */
+@Tag(MiscTests.TAG)
+@Tag(SmallTests.TAG)
+public class TestInfoServerTLSConfig {
+
+  private static final String POSTFIX = "keystore.location";
+  private static final String ROLE_SCOPED_KEY = "hbase.ui.ssl.server." + POSTFIX;
+  private static final String HBASE_KEY = "hbase.ui.ssl." + POSTFIX;
+  private static final String HADOOP_KEY = "ssl.server." + POSTFIX;
+
+  @Test
+  public void testRoleScopedTakesPrecedenceOverHBasePrefixed() {
+    Configuration c = new Configuration(false);
+    c.set(ROLE_SCOPED_KEY, "role-scoped-value");
+    c.set(HBASE_KEY, "hbase-prefixed-value");
+    c.set(HADOOP_KEY, "hadoop-prefixed-value");
+    assertEquals("role-scoped-value", InfoServer.getTLSProperty(c, POSTFIX));
+  }
+
+  @Test
+  public void testHBasePrefixedTakesPrecedenceOverHadoopPrefixed() {
+    Configuration c = new Configuration(false);
+    // No role-scoped key set — hbase-prefixed key must win.
+    c.set(HBASE_KEY, "hbase-prefixed-value");
+    c.set(HADOOP_KEY, "hadoop-prefixed-value");
+    assertEquals("hbase-prefixed-value", InfoServer.getTLSProperty(c, POSTFIX));
+  }
+
+  @Test
+  public void testFallsBackToHadoopPrefixedWhenNoOthersSet() {
+    Configuration c = new Configuration(false);
+    c.set(HADOOP_KEY, "hadoop-prefixed-value");
+    assertEquals("hadoop-prefixed-value", InfoServer.getTLSProperty(c, POSTFIX));
+  }
+
+  @Test
+  public void testReturnsDefaultWhenNoneSet() {
+    Configuration c = new Configuration(false);
+    assertEquals("jks", InfoServer.getTLSProperty(c, POSTFIX, "jks"));
+    assertNull(InfoServer.getTLSProperty(c, POSTFIX));
+  }
+
+  @Test
+  public void testGetTLSPasswordHonorsThreeTierFallback() throws Exception {
+    Configuration c = new Configuration(false);
+    // Configuration.getPassword returns null when unset; verifying the fallback picks each tier.
+    c.set(ROLE_SCOPED_KEY.replace("keystore.location", "keystore.password"), "role-pw");
+    c.set(HBASE_KEY.replace("keystore.location", "keystore.password"), "hbase-pw");
+    c.set(HADOOP_KEY.replace("keystore.location", "keystore.password"), "hadoop-pw");
+    assertEquals("role-pw", InfoServer.getTLSPassword(c, "keystore.password"));
+
+    c.unset(ROLE_SCOPED_KEY.replace("keystore.location", "keystore.password"));
+    assertEquals("hbase-pw", InfoServer.getTLSPassword(c, "keystore.password"));
+
+    c.unset(HBASE_KEY.replace("keystore.location", "keystore.password"));
+    assertEquals("hadoop-pw", InfoServer.getTLSPassword(c, "keystore.password"));
+
+    c.unset(HADOOP_KEY.replace("keystore.location", "keystore.password"));
+    assertNull(InfoServer.getTLSPassword(c, "keystore.password"));
+  }
+}
diff --git a/hbase-rest/src/main/java/org/apache/hadoop/hbase/rest/Constants.java b/hbase-rest/src/main/java/org/apache/hadoop/hbase/rest/Constants.java
index b3d603c660e..589c07054f4 100644
--- a/hbase-rest/src/main/java/org/apache/hadoop/hbase/rest/Constants.java
+++ b/hbase-rest/src/main/java/org/apache/hadoop/hbase/rest/Constants.java
@@ -60,6 +60,31 @@ public interface Constants {
   String REST_SSL_EXCLUDE_PROTOCOLS = "hbase.rest.ssl.exclude.protocols";
   String REST_SSL_INCLUDE_PROTOCOLS = "hbase.rest.ssl.include.protocols";
 
+  // ---------------------------------------------------------------------------
+  // Role-scoped SSL configuration for single-EKU certificate support.
+  //
+  // The server-scoped keys are what RESTServer.java actually reads: hbase.rest.ssl.server.*
+  // takes precedence over the corresponding unscoped hbase.rest.ssl.* keys; when unset the
+  // unscoped keys are used as a fallback so existing deployments keep working. RESTServer
+  // only ever plays the TLS-server role on its port, so no parallel .client.* configuration
+  // is defined.
+  // ---------------------------------------------------------------------------
+  String REST_SSL_SERVER_KEYSTORE_STORE = "hbase.rest.ssl.server.keystore.store";
+  String REST_SSL_SERVER_KEYSTORE_PASSWORD = "hbase.rest.ssl.server.keystore.password";
+  String REST_SSL_SERVER_KEYSTORE_KEYPASSWORD = "hbase.rest.ssl.server.keystore.keypassword";
+  String REST_SSL_SERVER_KEYSTORE_TYPE = "hbase.rest.ssl.server.keystore.type";
+  String REST_SSL_SERVER_TRUSTSTORE_STORE = "hbase.rest.ssl.server.truststore.store";
+  String REST_SSL_SERVER_TRUSTSTORE_PASSWORD = "hbase.rest.ssl.server.truststore.password";
+  String REST_SSL_SERVER_TRUSTSTORE_TYPE = "hbase.rest.ssl.server.truststore.type";
+
+  /**
+   * Client-auth mode for the REST server's TLS connector. Valid values: {@code NONE}, {@code WANT},
+   * {@code NEED} (see {@code X509Util.ClientAuth}). Default is {@code NONE}, which preserves the
+   * historical behavior of never requesting client certificates. Set to {@code NEED} to enforce
+   * mTLS.
+   */
+  String REST_SSL_CLIENT_AUTH_MODE = "hbase.rest.ssl.server.client.auth.mode";
+
   String REST_THREAD_POOL_THREADS_MAX = "hbase.rest.threads.max";
   String REST_THREAD_POOL_THREADS_MIN = "hbase.rest.threads.min";
   String REST_THREAD_POOL_TASK_QUEUE_SIZE = "hbase.rest.task.queue.size";
diff --git a/hbase-rest/src/main/java/org/apache/hadoop/hbase/rest/RESTServer.java b/hbase-rest/src/main/java/org/apache/hadoop/hbase/rest/RESTServer.java
index 149d634cbd5..100eaa88e0a 100644
--- a/hbase-rest/src/main/java/org/apache/hadoop/hbase/rest/RESTServer.java
+++ b/hbase-rest/src/main/java/org/apache/hadoop/hbase/rest/RESTServer.java
@@ -37,6 +37,8 @@ import org.apache.hadoop.hbase.HBaseInterfaceAudience;
 import org.apache.hadoop.hbase.ServerName;
 import org.apache.hadoop.hbase.http.HttpServerUtil;
 import org.apache.hadoop.hbase.http.InfoServer;
+import org.apache.hadoop.hbase.io.crypto.tls.TLSStore;
+import org.apache.hadoop.hbase.io.crypto.tls.X509Util;
 import org.apache.hadoop.hbase.log.HBaseMarkers;
 import org.apache.hadoop.hbase.rest.filter.AuthFilter;
 import org.apache.hadoop.hbase.rest.filter.GzipFilter;
@@ -305,11 +307,16 @@ public class RESTServer implements Constants {
       httpsConfig.addCustomizer(new SecureRequestCustomizer());
 
       SslContextFactory.Server sslCtxFactory = new SslContextFactory.Server();
-      String keystore = conf.get(REST_SSL_KEYSTORE_STORE);
-      String keystoreType = conf.get(REST_SSL_KEYSTORE_TYPE);
-      String password = HBaseConfiguration.getPassword(conf, REST_SSL_KEYSTORE_PASSWORD, null);
-      String keyPassword =
-        HBaseConfiguration.getPassword(conf, REST_SSL_KEYSTORE_KEYPASSWORD, password);
+      // Prefer the role-scoped hbase.rest.ssl.server.* keys, falling back to the historical
+      // unscoped hbase.rest.ssl.* keys for backward compatibility with existing deployments. A
+      // store resolves entirely from one prefix, so a role-scoped keystore is never paired with the
+      // legacy keystore's password or type.
+      TLSStore keyStore = TLSStore.resolve(conf, "hbase.rest.ssl.server.", "hbase.rest.ssl.",
+        TLSStore.Keys.SERVLET_KEYSTORE);
+      String keystore = keyStore.getLocation();
+      String keystoreType = keyStore.getType();
+      String password = keyStore.getPassword();
+      String keyPassword = keyStore.getKeyPassword();
       sslCtxFactory.setKeyStorePath(keystore);
       if (StringUtils.isNotBlank(keystoreType)) {
         sslCtxFactory.setKeyStoreType(keystoreType);
@@ -317,20 +324,44 @@ public class RESTServer implements Constants {
       sslCtxFactory.setKeyStorePassword(password);
       sslCtxFactory.setKeyManagerPassword(keyPassword);
 
-      String trustStore = conf.get(REST_SSL_TRUSTSTORE_STORE);
+      TLSStore trust = TLSStore.resolve(conf, "hbase.rest.ssl.server.", "hbase.rest.ssl.",
+        TLSStore.Keys.SERVLET_TRUSTSTORE);
+      String trustStore = trust.getLocation();
       if (StringUtils.isNotBlank(trustStore)) {
         sslCtxFactory.setTrustStorePath(trustStore);
       }
-      String trustStorePassword =
-        HBaseConfiguration.getPassword(conf, REST_SSL_TRUSTSTORE_PASSWORD, null);
+      String trustStorePassword = trust.getPassword();
       if (StringUtils.isNotBlank(trustStorePassword)) {
         sslCtxFactory.setTrustStorePassword(trustStorePassword);
       }
-      String trustStoreType = conf.get(REST_SSL_TRUSTSTORE_TYPE);
+      String trustStoreType = trust.getType();
       if (StringUtils.isNotBlank(trustStoreType)) {
         sslCtxFactory.setTrustStoreType(trustStoreType);
       }
 
+      // Activate mTLS if configured. Default is NONE, which preserves today's behavior of never
+      // requesting a client certificate — even when a truststore is configured. Set
+      // hbase.rest.ssl.server.client.auth.mode to WANT or NEED to opt in.
+      // getTrimmed + defaultIfBlank so that an empty or whitespace-only <value></value> in
+      // hbase-site.xml behaves like an absent key. A bare fromPropertyValue("") would return NEED.
+      X509Util.ClientAuth clientAuth =
+        X509Util.ClientAuth.fromPropertyValue(StringUtils.defaultIfBlank(
+          conf.getTrimmed(REST_SSL_CLIENT_AUTH_MODE), X509Util.ClientAuth.NONE.name()));
+      X509Util.validateClientAuthTrustStore(clientAuth, trustStore, REST_SSL_CLIENT_AUTH_MODE,
+        REST_SSL_SERVER_TRUSTSTORE_STORE, REST_SSL_TRUSTSTORE_STORE);
+      switch (clientAuth) {
+        case NEED:
+          sslCtxFactory.setNeedClientAuth(true);
+          break;
+        case WANT:
+          sslCtxFactory.setWantClientAuth(true);
+          break;
+        case NONE:
+        default:
+          // no-op; both flags default to false on SslContextFactory.Server
+          break;
+      }
+
       String[] excludeCiphers = servlet.getConfiguration()
         .getStrings(REST_SSL_EXCLUDE_CIPHER_SUITES, ArrayUtils.EMPTY_STRING_ARRAY);
       if (excludeCiphers.length != 0) {
diff --git a/hbase-rest/src/test/java/org/apache/hadoop/hbase/rest/TestRESTServerSSL.java b/hbase-rest/src/test/java/org/apache/hadoop/hbase/rest/TestRESTServerSSL.java
index a123f714a01..12905672318 100644
--- a/hbase-rest/src/test/java/org/apache/hadoop/hbase/rest/TestRESTServerSSL.java
+++ b/hbase-rest/src/test/java/org/apache/hadoop/hbase/rest/TestRESTServerSSL.java
@@ -21,6 +21,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 
 import java.io.File;
+import java.io.IOException;
 import java.lang.reflect.Method;
 import java.security.KeyPair;
 import java.security.cert.X509Certificate;
@@ -160,6 +161,98 @@ public class TestRESTServerSSL {
     assertEquals(200, response.getCode());
   }
 
+  // ---------------------------------------------------------------------------
+  // Role-scoped configuration + mTLS (single-EKU certificate support).
+  // ---------------------------------------------------------------------------
+
+  /**
+   * Server started with only the role-scoped hbase.rest.ssl.server.* keys (and legacy keys unset).
+   * A successful SSL connection proves the server-scoped keys were consulted.
+   */
+  @Test
+  public void testSslConnectionUsingRoleScopedServerKeys() throws Exception {
+    // Move the legacy passwords set in beforeEachTest onto the role-scoped keys and clear the
+    // legacy passwords so a successful start proves the server-scoped keys are what the code
+    // actually picked up.
+    conf.unset(Constants.REST_SSL_KEYSTORE_PASSWORD);
+    conf.unset(Constants.REST_SSL_KEYSTORE_KEYPASSWORD);
+    conf.unset(Constants.REST_SSL_TRUSTSTORE_PASSWORD);
+    conf.set(Constants.REST_SSL_SERVER_KEYSTORE_PASSWORD, KEY_STORE_PASSWORD);
+    conf.set(Constants.REST_SSL_SERVER_KEYSTORE_KEYPASSWORD, KEY_STORE_PASSWORD);
+    conf.set(Constants.REST_SSL_SERVER_TRUSTSTORE_PASSWORD, TRUST_STORE_PASSWORD);
+    conf.set(Constants.REST_SSL_SERVER_KEYSTORE_STORE, getKeystoreFilePath("jks"));
+    conf.set(Constants.REST_SSL_SERVER_TRUSTSTORE_STORE, getTruststoreFilePath("jks"));
+
+    REST_TEST_UTIL.startServletContainer(conf);
+    Cluster localCluster = new Cluster().add("localhost", REST_TEST_UTIL.getServletPort());
+    sslClient = new Client(localCluster, getTruststoreFilePath("jks"),
+      Optional.of(TRUST_STORE_PASSWORD), Optional.empty());
+
+    Response response = sslClient.get("/version", Constants.MIMETYPE_TEXT);
+    assertEquals(200, response.getCode());
+  }
+
+  /**
+   * Backward-compatibility regression: existing deployments that know only about the legacy
+   * unscoped keys must continue to work exactly as before. This mirrors {@link #testSslConnection}
+   * but names the intent explicitly.
+   */
+  @Test
+  public void testSslConnectionFallsBackToLegacyKeystoreKeys() throws Exception {
+    // Make sure no role-scoped key is set — the beforeEachTest configures only legacy passwords,
+    // so this is a fresh state check.
+    conf.unset(Constants.REST_SSL_SERVER_KEYSTORE_STORE);
+    conf.unset(Constants.REST_SSL_SERVER_KEYSTORE_PASSWORD);
+    conf.unset(Constants.REST_SSL_SERVER_KEYSTORE_KEYPASSWORD);
+    conf.unset(Constants.REST_SSL_SERVER_KEYSTORE_TYPE);
+    conf.unset(Constants.REST_SSL_SERVER_TRUSTSTORE_STORE);
+    conf.unset(Constants.REST_SSL_SERVER_TRUSTSTORE_PASSWORD);
+    conf.unset(Constants.REST_SSL_SERVER_TRUSTSTORE_TYPE);
+
+    startRESTServerWithDefaultKeystoreType();
+
+    Response response = sslClient.get("/version", Constants.MIMETYPE_TEXT);
+    assertEquals(200, response.getCode());
+  }
+
+  /**
+   * With {@code client.auth.mode=NONE} (the default), a client that presents no client certificate
+   * is accepted — matching today's behavior.
+   */
+  @Test
+  public void testClientAuthNoneAcceptsClientWithoutCert() throws Exception {
+    conf.set(Constants.REST_SSL_CLIENT_AUTH_MODE, "NONE");
+    startRESTServerWithDefaultKeystoreType();
+
+    Response response = sslClient.get("/version", Constants.MIMETYPE_TEXT);
+    assertEquals(200, response.getCode());
+  }
+
+  /**
+   * With {@code client.auth.mode=WANT}, an anonymous client (no client cert) is still accepted; the
+   * server requests a cert but does not require it.
+   */
+  @Test
+  public void testClientAuthWantAllowsAnonymousClient() throws Exception {
+    conf.set(Constants.REST_SSL_CLIENT_AUTH_MODE, "WANT");
+    startRESTServerWithDefaultKeystoreType();
+
+    Response response = sslClient.get("/version", Constants.MIMETYPE_TEXT);
+    assertEquals(200, response.getCode());
+  }
+
+  /**
+   * With {@code client.auth.mode=NEED}, an anonymous client (no client cert) is rejected by the
+   * server. The base {@link Client} configures truststore-only, so it presents no key material to
+   * the server.
+   */
+  @Test
+  public void testClientAuthNeedRejectsClientWithoutCert() throws Exception {
+    conf.set(Constants.REST_SSL_CLIENT_AUTH_MODE, "NEED");
+    startRESTServerWithDefaultKeystoreType();
+    assertThrows(IOException.class, () -> sslClient.get("/version"));
+  }
+
   private static File initKeystoreDir() {
     String dataTestDir = TEST_UTIL.getDataTestDir().toString();
     File keystoreDir = new File(dataTestDir, TestRESTServerSSL.class.getSimpleName() + "_keys");
diff --git a/hbase-server/src/main/java/org/apache/hadoop/hbase/ipc/NettyRpcServer.java b/hbase-server/src/main/java/org/apache/hadoop/hbase/ipc/NettyRpcServer.java
index b21b6e19c78..718279cc800 100644
--- a/hbase-server/src/main/java/org/apache/hadoop/hbase/ipc/NettyRpcServer.java
+++ b/hbase-server/src/main/java/org/apache/hadoop/hbase/ipc/NettyRpcServer.java
@@ -465,7 +465,7 @@ public class NettyRpcServer extends RpcServer {
         keyStoreWatcher.get() == null && trustStoreWatcher.get() == null
           && conf.getBoolean(X509Util.TLS_CERT_RELOAD, false)
       ) {
-        X509Util.enableCertFileReloading(conf, keyStoreWatcher, trustStoreWatcher,
+        X509Util.enableCertFileReloadingForServer(conf, keyStoreWatcher, trustStoreWatcher,
           () -> sslContextForServer.set(null));
       }
     }
diff --git a/hbase-thrift/src/main/java/org/apache/hadoop/hbase/thrift/Constants.java b/hbase-thrift/src/main/java/org/apache/hadoop/hbase/thrift/Constants.java
index 83bb90eecef..5766cfec83a 100644
--- a/hbase-thrift/src/main/java/org/apache/hadoop/hbase/thrift/Constants.java
+++ b/hbase-thrift/src/main/java/org/apache/hadoop/hbase/thrift/Constants.java
@@ -71,6 +71,35 @@ public final class Constants {
   public static final String THRIFT_SSL_KEYSTORE_TYPE_KEY = "hbase.thrift.ssl.keystore.type";
   public static final String THRIFT_SSL_KEYSTORE_TYPE_DEFAULT = "jks";
 
+  /**
+   * Role-scoped SSL configuration for single-EKU certificate support. The server-scoped
+   * hbase.thrift.ssl.server.* keys are what ThriftServer reads on the Thrift-over-HTTP transport;
+   * when set they take precedence over the corresponding unscoped hbase.thrift.ssl.* keys; when
+   * unset the unscoped keys are used as a fallback so existing deployments keep working.
+   */
+  public static final String THRIFT_SSL_SERVER_KEYSTORE_STORE_KEY =
+    "hbase.thrift.ssl.server.keystore.store";
+  public static final String THRIFT_SSL_SERVER_KEYSTORE_PASSWORD_KEY =
+    "hbase.thrift.ssl.server.keystore.password";
+  public static final String THRIFT_SSL_SERVER_KEYSTORE_KEYPASSWORD_KEY =
+    "hbase.thrift.ssl.server.keystore.keypassword";
+  public static final String THRIFT_SSL_SERVER_KEYSTORE_TYPE_KEY =
+    "hbase.thrift.ssl.server.keystore.type";
+  public static final String THRIFT_SSL_SERVER_TRUSTSTORE_STORE_KEY =
+    "hbase.thrift.ssl.server.truststore.store";
+  public static final String THRIFT_SSL_SERVER_TRUSTSTORE_PASSWORD_KEY =
+    "hbase.thrift.ssl.server.truststore.password";
+  public static final String THRIFT_SSL_SERVER_TRUSTSTORE_TYPE_KEY =
+    "hbase.thrift.ssl.server.truststore.type";
+
+  /**
+   * Client-auth mode for the Thrift server's HTTP-transport TLS connector. Valid values:
+   * {@code NONE}, {@code WANT}, {@code NEED} (see {@code X509Util.ClientAuth}). Default is
+   * {@code NONE}, which preserves the historical behavior of never requesting client certificates.
+   */
+  public static final String THRIFT_SSL_CLIENT_AUTH_MODE_KEY =
+    "hbase.thrift.ssl.server.client.auth.mode";
+
   public static final String THRIFT_SUPPORT_PROXYUSER_KEY = "hbase.thrift.support.proxyuser";
 
   // kerberos related configs
diff --git a/hbase-thrift/src/main/java/org/apache/hadoop/hbase/thrift/ThriftServer.java b/hbase-thrift/src/main/java/org/apache/hadoop/hbase/thrift/ThriftServer.java
index 38be5daf77f..dbc438a22a2 100644
--- a/hbase-thrift/src/main/java/org/apache/hadoop/hbase/thrift/ThriftServer.java
+++ b/hbase-thrift/src/main/java/org/apache/hadoop/hbase/thrift/ThriftServer.java
@@ -61,16 +61,16 @@ import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SERVER_SOCKET_READ
 import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SERVER_SOCKET_READ_TIMEOUT_KEY;
 import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SPNEGO_KEYTAB_FILE_KEY;
 import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SPNEGO_PRINCIPAL_KEY;
+import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SSL_CLIENT_AUTH_MODE_KEY;
 import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SSL_ENABLED_KEY;
 import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SSL_EXCLUDE_CIPHER_SUITES_KEY;
 import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SSL_EXCLUDE_PROTOCOLS_KEY;
 import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SSL_INCLUDE_CIPHER_SUITES_KEY;
 import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SSL_INCLUDE_PROTOCOLS_KEY;
-import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SSL_KEYSTORE_KEYPASSWORD_KEY;
-import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SSL_KEYSTORE_PASSWORD_KEY;
-import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SSL_KEYSTORE_STORE_KEY;
 import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SSL_KEYSTORE_TYPE_DEFAULT;
-import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SSL_KEYSTORE_TYPE_KEY;
+import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SSL_SERVER_TRUSTSTORE_PASSWORD_KEY;
+import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SSL_SERVER_TRUSTSTORE_STORE_KEY;
+import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SSL_SERVER_TRUSTSTORE_TYPE_KEY;
 import static org.apache.hadoop.hbase.thrift.Constants.THRIFT_SUPPORT_PROXYUSER_KEY;
 import static org.apache.hadoop.hbase.thrift.Constants.USE_HTTP_CONF_KEY;
 
@@ -91,6 +91,7 @@ import javax.security.auth.callback.UnsupportedCallbackException;
 import javax.security.sasl.AuthorizeCallback;
 import javax.security.sasl.SaslServer;
 import org.apache.commons.lang3.ArrayUtils;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.hadoop.conf.Configuration;
 import org.apache.hadoop.conf.Configured;
 import org.apache.hadoop.hbase.HBaseConfiguration;
@@ -98,6 +99,8 @@ import org.apache.hadoop.hbase.HBaseInterfaceAudience;
 import org.apache.hadoop.hbase.filter.ParseFilter;
 import org.apache.hadoop.hbase.http.HttpServerUtil;
 import org.apache.hadoop.hbase.http.InfoServer;
+import org.apache.hadoop.hbase.io.crypto.tls.TLSStore;
+import org.apache.hadoop.hbase.io.crypto.tls.X509Util;
 import org.apache.hadoop.hbase.log.HBaseMarkers;
 import org.apache.hadoop.hbase.security.SaslUtil;
 import org.apache.hadoop.hbase.security.SecurityUtil;
@@ -408,16 +411,57 @@ public class ThriftServer extends Configured implements Tool {
       httpsConfig.addCustomizer(new SecureRequestCustomizer());
 
       SslContextFactory.Server sslCtxFactory = new SslContextFactory.Server();
-      String keystore = conf.get(THRIFT_SSL_KEYSTORE_STORE_KEY);
-      String password =
-        HBaseConfiguration.getPassword(conf, THRIFT_SSL_KEYSTORE_PASSWORD_KEY, null);
-      String keyPassword =
-        HBaseConfiguration.getPassword(conf, THRIFT_SSL_KEYSTORE_KEYPASSWORD_KEY, password);
-      sslCtxFactory.setKeyStorePath(keystore);
-      sslCtxFactory.setKeyStorePassword(password);
-      sslCtxFactory.setKeyManagerPassword(keyPassword);
-      sslCtxFactory
-        .setKeyStoreType(conf.get(THRIFT_SSL_KEYSTORE_TYPE_KEY, THRIFT_SSL_KEYSTORE_TYPE_DEFAULT));
+      // Prefer the role-scoped hbase.thrift.ssl.server.* keys, falling back to the historical
+      // unscoped hbase.thrift.ssl.* keys for backward compatibility with existing deployments. A
+      // store resolves entirely from one prefix, so a role-scoped keystore is never paired with the
+      // legacy keystore's password or type.
+      TLSStore keyStore = TLSStore.resolve(conf, "hbase.thrift.ssl.server.", "hbase.thrift.ssl.",
+        TLSStore.Keys.SERVLET_KEYSTORE);
+      sslCtxFactory.setKeyStorePath(keyStore.getLocation());
+      sslCtxFactory.setKeyStorePassword(keyStore.getPassword());
+      sslCtxFactory.setKeyManagerPassword(keyStore.getKeyPassword());
+      sslCtxFactory.setKeyStoreType(
+        StringUtils.defaultIfBlank(keyStore.getType(), THRIFT_SSL_KEYSTORE_TYPE_DEFAULT));
+
+      // Truststore is entirely new for Thrift — no legacy fallback because there is no historical
+      // hbase.thrift.ssl.truststore.* configuration. Leaving it unset is only valid when client
+      // auth is NONE
+      String trustStore = conf.get(THRIFT_SSL_SERVER_TRUSTSTORE_STORE_KEY);
+      if (StringUtils.isNotBlank(trustStore)) {
+        sslCtxFactory.setTrustStorePath(trustStore);
+        String trustStorePassword =
+          HBaseConfiguration.getPassword(conf, THRIFT_SSL_SERVER_TRUSTSTORE_PASSWORD_KEY, null);
+        if (StringUtils.isNotBlank(trustStorePassword)) {
+          sslCtxFactory.setTrustStorePassword(trustStorePassword);
+        }
+        String trustStoreType = conf.get(THRIFT_SSL_SERVER_TRUSTSTORE_TYPE_KEY);
+        if (StringUtils.isNotBlank(trustStoreType)) {
+          sslCtxFactory.setTrustStoreType(trustStoreType);
+        }
+      }
+
+      // Activate mTLS if configured. Default is NONE, which preserves today's behavior of never
+      // requesting a client certificate on the Thrift-over-HTTP connector. Set
+      // hbase.thrift.ssl.server.client.auth.mode to WANT or NEED to opt in.
+      // getTrimmed + defaultIfBlank so that an empty or whitespace-only <value></value> in
+      // hbase-site.xml behaves like an absent key. A bare fromPropertyValue("") would return NEED.
+      X509Util.ClientAuth clientAuth =
+        X509Util.ClientAuth.fromPropertyValue(StringUtils.defaultIfBlank(
+          conf.getTrimmed(THRIFT_SSL_CLIENT_AUTH_MODE_KEY), X509Util.ClientAuth.NONE.name()));
+      X509Util.validateClientAuthTrustStore(clientAuth, trustStore, THRIFT_SSL_CLIENT_AUTH_MODE_KEY,
+        THRIFT_SSL_SERVER_TRUSTSTORE_STORE_KEY);
+      switch (clientAuth) {
+        case NEED:
+          sslCtxFactory.setNeedClientAuth(true);
+          break;
+        case WANT:
+          sslCtxFactory.setWantClientAuth(true);
+          break;
+        case NONE:
+        default:
+          // no-op; both flags default to false on SslContextFactory.Server
+          break;
+      }
 
       String[] excludeCiphers =
         conf.getStrings(THRIFT_SSL_EXCLUDE_CIPHER_SUITES_KEY, ArrayUtils.EMPTY_STRING_ARRAY);
diff --git a/hbase-thrift/src/test/java/org/apache/hadoop/hbase/thrift/TestThriftServerSSLMutualAuth.java b/hbase-thrift/src/test/java/org/apache/hadoop/hbase/thrift/TestThriftServerSSLMutualAuth.java
new file mode 100644
index 00000000000..37d7c340c61
--- /dev/null
+++ b/hbase-thrift/src/test/java/org/apache/hadoop/hbase/thrift/TestThriftServerSSLMutualAuth.java
@@ -0,0 +1,361 @@
+/*
+ * 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.hadoop.hbase.thrift;
+
+import static org.apache.hadoop.hbase.thrift.TestThriftServerCmdLine.createBoundServer;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.BufferedInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Method;
+import java.net.HttpURLConnection;
+import java.nio.file.Files;
+import java.security.KeyPair;
+import java.security.KeyStore;
+import java.security.cert.X509Certificate;
+import javax.net.ssl.SSLContext;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hbase.HBaseTestingUtil;
+import org.apache.hadoop.hbase.HConstants;
+import org.apache.hadoop.hbase.testclassification.ClientTests;
+import org.apache.hadoop.hbase.testclassification.LargeTests;
+import org.apache.hadoop.hbase.thrift.generated.Hbase;
+import org.apache.hadoop.hbase.util.EnvironmentEdgeManager;
+import org.apache.hadoop.hbase.util.EnvironmentEdgeManagerTestHelper;
+import org.apache.hadoop.hbase.util.IncrementingEnvironmentEdge;
+import org.apache.hadoop.hbase.util.TableDescriptorChecker;
+import org.apache.hadoop.security.ssl.KeyStoreTestUtil;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.entity.ByteArrayEntity;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClientBuilder;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.ssl.SSLContexts;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.hbase.thirdparty.org.apache.thrift.protocol.TBinaryProtocol;
+import org.apache.hbase.thirdparty.org.apache.thrift.protocol.TProtocol;
+import org.apache.hbase.thirdparty.org.apache.thrift.transport.TMemoryBuffer;
+
+/**
+ * Exercises the role-scoped {@code hbase.thrift.ssl.server.*} configuration and the new mutual-TLS
+ * activation on the Thrift-over-HTTP transport. Complements {@link TestThriftHttpServerSSL}, which
+ * covers plain (server-only) TLS termination.
+ */
+@Tag(ClientTests.TAG)
+@Tag(LargeTests.TAG)
+public class TestThriftServerSSLMutualAuth {
+
+  private static final Logger LOG = LoggerFactory.getLogger(TestThriftServerSSLMutualAuth.class);
+  private static final HBaseTestingUtil TEST_UTIL = new HBaseTestingUtil();
+  private static final String KEY_STORE_PASSWORD = "myKSPassword";
+  private static final String TRUST_STORE_PASSWORD = "myTSPassword";
+  private static final String CLIENT_KEY_STORE_PASSWORD = "myClientKSPassword";
+
+  private File keyDir;
+  private ThriftServerRunner tsr;
+  private HttpPost httpPost;
+
+  @BeforeAll
+  public static void setUpBeforeClass() throws Exception {
+    TEST_UTIL.getConfiguration().setBoolean(Constants.USE_HTTP_CONF_KEY, true);
+    TEST_UTIL.getConfiguration().setBoolean(TableDescriptorChecker.TABLE_SANITY_CHECKS, false);
+    TEST_UTIL.startMiniCluster();
+    EnvironmentEdgeManagerTestHelper.injectEdge(new IncrementingEnvironmentEdge());
+  }
+
+  @AfterAll
+  public static void tearDownAfterClass() throws Exception {
+    TEST_UTIL.shutdownMiniCluster();
+    EnvironmentEdgeManager.reset();
+  }
+
+  @BeforeEach
+  public void setUp() throws Exception {
+    initializeAlgorithmId();
+    keyDir = initKeystoreDir();
+    keyDir.deleteOnExit();
+
+    // Server identity + a truststore holding the server's cert (used by the test client to
+    // trust the server).
+    KeyPair serverKeyPair = KeyStoreTestUtil.generateKeyPair("RSA");
+    X509Certificate serverCertificate = KeyStoreTestUtil
+      .generateCertificate("CN=localhost, O=server", serverKeyPair, 30, "SHA1withRSA");
+    generateTrustStore(getServerTruststoreFilePath(), serverCertificate);
+    generateKeyStore(getServerKeystoreFilePath(), serverKeyPair, serverCertificate);
+
+    // Distinct client cert (single-EKU clientAuth in spirit) and a truststore holding that cert
+    // — this is what the server uses to validate presented client certificates.
+    KeyPair clientKeyPair = KeyStoreTestUtil.generateKeyPair("RSA");
+    X509Certificate clientCertificate =
+      KeyStoreTestUtil.generateCertificate("CN=client, O=client", clientKeyPair, 30, "SHA1withRSA");
+    generateTrustStore(getClientCaTruststoreFilePath(), clientCertificate);
+    generateKeyStoreWithPassword(getClientKeystoreFilePath(), clientKeyPair, clientCertificate,
+      CLIENT_KEY_STORE_PASSWORD);
+  }
+
+  @AfterEach
+  public void tearDown() throws IOException {
+    if (httpPost != null) {
+      httpPost.releaseConnection();
+    }
+    if (tsr != null) {
+      tsr.close();
+    }
+  }
+
+  // ---------------------------------------------------------------------------
+  // Role-scoped keystore configuration.
+  // ---------------------------------------------------------------------------
+
+  /**
+   * With only the role-scoped {@code hbase.thrift.ssl.server.keystore.*} keys set (legacy keys
+   * unset), a plain HTTPS request succeeds — proving the server-scoped keys are the ones the
+   * bootstrap actually reads.
+   */
+  @Test
+  public void testServerUsesRoleScopedKeystoreWhenSet() throws Exception {
+    Configuration conf = baseConf();
+    conf.set(Constants.THRIFT_SSL_SERVER_KEYSTORE_STORE_KEY, getServerKeystoreFilePath());
+    conf.set(Constants.THRIFT_SSL_SERVER_KEYSTORE_PASSWORD_KEY, KEY_STORE_PASSWORD);
+    conf.set(Constants.THRIFT_SSL_SERVER_KEYSTORE_KEYPASSWORD_KEY, KEY_STORE_PASSWORD);
+
+    startServer(conf);
+    doRequestExpectingSuccess(clientBuilderWithServerTrust());
+  }
+
+  /**
+   * Backward-compatibility regression: existing deployments that know only about the legacy
+   * unscoped keys must continue to work.
+   */
+  @Test
+  public void testServerFallsBackToLegacyKeystore() throws Exception {
+    Configuration conf = baseConf();
+    conf.set(Constants.THRIFT_SSL_KEYSTORE_STORE_KEY, getServerKeystoreFilePath());
+    conf.set(Constants.THRIFT_SSL_KEYSTORE_PASSWORD_KEY, KEY_STORE_PASSWORD);
+    conf.set(Constants.THRIFT_SSL_KEYSTORE_KEYPASSWORD_KEY, KEY_STORE_PASSWORD);
+
+    startServer(conf);
+    doRequestExpectingSuccess(clientBuilderWithServerTrust());
+  }
+
+  // ---------------------------------------------------------------------------
+  // Mutual TLS (client auth mode).
+  // ---------------------------------------------------------------------------
+
+  /**
+   * With {@code client.auth.mode=NONE} (default), a client presenting no certificate is accepted —
+   * matches today's behavior.
+   */
+  @Test
+  public void testClientAuthNoneAcceptsClientWithoutCert() throws Exception {
+    Configuration conf = baseConfWithLegacyKeystore();
+    conf.set(Constants.THRIFT_SSL_CLIENT_AUTH_MODE_KEY, "NONE");
+
+    startServer(conf);
+    doRequestExpectingSuccess(clientBuilderWithServerTrust());
+  }
+
+  /** {@code WANT} lets anonymous clients through. */
+  @Test
+  public void testClientAuthWantAllowsAnonymousClient() throws Exception {
+    Configuration conf = baseConfWithLegacyKeystore();
+    // WANT needs the server to know which CAs it would trust if a client did present a cert.
+    conf.set(Constants.THRIFT_SSL_SERVER_TRUSTSTORE_STORE_KEY, getClientCaTruststoreFilePath());
+    conf.set(Constants.THRIFT_SSL_SERVER_TRUSTSTORE_PASSWORD_KEY, TRUST_STORE_PASSWORD);
+    conf.set(Constants.THRIFT_SSL_CLIENT_AUTH_MODE_KEY, "WANT");
+
+    startServer(conf);
+    doRequestExpectingSuccess(clientBuilderWithServerTrust());
+  }
+
+  /**
+   * {@code NEED} rejects clients that do not present a valid client certificate. The handshake
+   * fails before any HTTP response is produced.
+   */
+  @Test
+  public void testClientAuthNeedRejectsClientWithoutCert() throws Exception {
+    Configuration conf = baseConfWithLegacyKeystore();
+    conf.set(Constants.THRIFT_SSL_SERVER_TRUSTSTORE_STORE_KEY, getClientCaTruststoreFilePath());
+    conf.set(Constants.THRIFT_SSL_SERVER_TRUSTSTORE_PASSWORD_KEY, TRUST_STORE_PASSWORD);
+    conf.set(Constants.THRIFT_SSL_CLIENT_AUTH_MODE_KEY, "NEED");
+
+    startServer(conf);
+    // The Apache HttpClient surface may raise either SSLHandshakeException directly or wrap it
+    // in an IOException — both are acceptable evidence that the handshake was refused.
+    assertThrows(IOException.class, () -> doRequest(clientBuilderWithServerTrust()));
+  }
+
+  /** {@code NEED} accepts a client that presents a certificate trusted by the server. */
+  @Test
+  public void testClientAuthNeedAcceptsClientWithValidCert() throws Exception {
+    Configuration conf = baseConfWithLegacyKeystore();
+    conf.set(Constants.THRIFT_SSL_SERVER_TRUSTSTORE_STORE_KEY, getClientCaTruststoreFilePath());
+    conf.set(Constants.THRIFT_SSL_SERVER_TRUSTSTORE_PASSWORD_KEY, TRUST_STORE_PASSWORD);
+    conf.set(Constants.THRIFT_SSL_CLIENT_AUTH_MODE_KEY, "NEED");
+
+    startServer(conf);
+    doRequestExpectingSuccess(clientBuilderWithMutualTrust());
+  }
+
+  // ---------------------------------------------------------------------------
+  // Helpers
+  // ---------------------------------------------------------------------------
+
+  private Configuration baseConf() throws Exception {
+    Configuration conf = new Configuration(TEST_UTIL.getConfiguration());
+    conf.setBoolean(Constants.THRIFT_SSL_ENABLED_KEY, true);
+    return conf;
+  }
+
+  private Configuration baseConfWithLegacyKeystore() throws Exception {
+    Configuration conf = baseConf();
+    conf.set(Constants.THRIFT_SSL_KEYSTORE_STORE_KEY, getServerKeystoreFilePath());
+    conf.set(Constants.THRIFT_SSL_KEYSTORE_PASSWORD_KEY, KEY_STORE_PASSWORD);
+    conf.set(Constants.THRIFT_SSL_KEYSTORE_KEYPASSWORD_KEY, KEY_STORE_PASSWORD);
+    return conf;
+  }
+
+  private void startServer(Configuration conf) throws Exception {
+    tsr = createBoundServer(() -> new ThriftServer(conf));
+    String url = "https://" + HConstants.LOCALHOST + ":" + tsr.getThriftServer().listenPort;
+    httpPost = new HttpPost(url);
+    httpPost.setHeader("Content-Type", "application/x-thrift");
+    httpPost.setHeader("Accept", "application/x-thrift");
+    httpPost.setHeader("User-Agent", "Java/THttpClient/HC");
+  }
+
+  private HttpClientBuilder clientBuilderWithServerTrust() throws Exception {
+    KeyStore trustStore = loadJksTrustStore(getServerTruststoreFilePath(), TRUST_STORE_PASSWORD);
+    SSLContext sslContext = SSLContexts.custom().loadTrustMaterial(trustStore, null).build();
+    return HttpClients.custom().setSSLContext(sslContext);
+  }
+
+  /**
+   * Builds a client that (a) trusts the server's cert and (b) presents the client keystore. Used to
+   * satisfy {@code client.auth.mode=NEED}.
+   */
+  private HttpClientBuilder clientBuilderWithMutualTrust() throws Exception {
+    KeyStore trustStore = loadJksTrustStore(getServerTruststoreFilePath(), TRUST_STORE_PASSWORD);
+    KeyStore clientKs;
+    try (InputStream in = new BufferedInputStream(
+      Files.newInputStream(new File(getClientKeystoreFilePath()).toPath()))) {
+      clientKs = KeyStore.getInstance("JKS");
+      clientKs.load(in, CLIENT_KEY_STORE_PASSWORD.toCharArray());
+    }
+    SSLContext sslContext = SSLContexts.custom().loadTrustMaterial(trustStore, null)
+      .loadKeyMaterial(clientKs, CLIENT_KEY_STORE_PASSWORD.toCharArray()).build();
+    return HttpClients.custom().setSSLContext(sslContext);
+  }
+
+  private void doRequestExpectingSuccess(HttpClientBuilder builder) throws Exception {
+    try (CloseableHttpClient httpClient = builder.build()) {
+      CloseableHttpResponse response = doOneRoundTrip(httpClient);
+      assertEquals(HttpURLConnection.HTTP_OK, response.getStatusLine().getStatusCode());
+    }
+  }
+
+  private void doRequest(HttpClientBuilder builder) throws Exception {
+    try (CloseableHttpClient httpClient = builder.build()) {
+      doOneRoundTrip(httpClient);
+    }
+  }
+
+  private CloseableHttpResponse doOneRoundTrip(CloseableHttpClient httpClient) throws Exception {
+    TMemoryBuffer memoryBuffer = new TMemoryBuffer(100);
+    TProtocol prot = new TBinaryProtocol(memoryBuffer);
+    Hbase.Client client = new Hbase.Client(prot);
+    client.send_getClusterId();
+    httpPost.setEntity(new ByteArrayEntity(memoryBuffer.getArray()));
+    return httpClient.execute(httpPost);
+  }
+
+  private static KeyStore loadJksTrustStore(String path, String password) throws Exception {
+    try (InputStream in = new BufferedInputStream(Files.newInputStream(new File(path).toPath()))) {
+      KeyStore ks = KeyStore.getInstance("JKS");
+      ks.load(in, password.toCharArray());
+      return ks;
+    }
+  }
+
+  // Workaround for jdk8 292 bug. See https://github.com/bcgit/bc-java/issues/941
+  // Below is a workaround described in above URL. Issue fingered first in comments in
+  // HBASE-25920 Support Hadoop 3.3.1
+  private static void initializeAlgorithmId() {
+    try {
+      Class<?> algoId = Class.forName("sun.security.x509.AlgorithmId");
+      Method method = algoId.getMethod("get", String.class);
+      method.setAccessible(true);
+      method.invoke(null, "PBEWithSHA1AndDESede");
+    } catch (Exception e) {
+      LOG.warn("failed to initialize AlgorithmId", e);
+    }
+  }
+
+  private File initKeystoreDir() {
+    String dataTestDir = TEST_UTIL.getDataTestDir().toString();
+    File keystoreDir =
+      new File(dataTestDir, TestThriftServerSSLMutualAuth.class.getSimpleName() + "_keys");
+    keystoreDir.mkdirs();
+    return keystoreDir;
+  }
+
+  private static void generateKeyStore(String keyStorePath, KeyPair keyPair, X509Certificate cert)
+    throws Exception {
+    KeyStoreTestUtil.createKeyStore(keyStorePath, KEY_STORE_PASSWORD, KEY_STORE_PASSWORD,
+      "serverKS", keyPair.getPrivate(), cert);
+  }
+
+  private static void generateKeyStoreWithPassword(String keyStorePath, KeyPair keyPair,
+    X509Certificate cert, String password) throws Exception {
+    KeyStoreTestUtil.createKeyStore(keyStorePath, password, password, "clientKS",
+      keyPair.getPrivate(), cert);
+  }
+
+  private static void generateTrustStore(String path, X509Certificate cert) throws Exception {
+    KeyStoreTestUtil.createTrustStore(path, TRUST_STORE_PASSWORD, "ts", cert);
+  }
+
+  private String getServerKeystoreFilePath() {
+    return String.format("%s/serverKS.jks", keyDir.getAbsolutePath());
+  }
+
+  private String getServerTruststoreFilePath() {
+    return String.format("%s/serverTS.jks", keyDir.getAbsolutePath());
+  }
+
+  private String getClientKeystoreFilePath() {
+    return String.format("%s/clientKS.jks", keyDir.getAbsolutePath());
+  }
+
+  private String getClientCaTruststoreFilePath() {
+    return String.format("%s/clientCA.jks", keyDir.getAbsolutePath());
+  }
+
+}