You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@pulsar.apache.org by lh...@apache.org on 2026/08/12 16:31:36 UTC

(pulsar) branch master updated: [feat][client] PIP-478: asynchronous v5 client authentication and TLS factory integration (core migration) (#26282)

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

lhotari pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pulsar.git


The following commit(s) were added to refs/heads/master by this push:
     new fd7eebbb1036 [feat][client] PIP-478: asynchronous v5 client authentication and TLS factory integration (core migration) (#26282)
fd7eebbb1036 is described below

commit fd7eebbb10365910374d2374a0cff37acee1fecd
Author: Lari Hotari <lh...@users.noreply.github.com>
AuthorDate: Wed Aug 12 19:31:29 2026 +0300

    [feat][client] PIP-478: asynchronous v5 client authentication and TLS factory integration (core migration) (#26282)
---
 conf/broker.conf                                   |  50 +-
 conf/client.conf                                   |  14 +-
 conf/functions_worker.yml                          |  38 +-
 conf/proxy.conf                                    |  49 +-
 conf/standalone.conf                               |  45 +-
 conf/websocket.conf                                |  31 +-
 pip/pip-478.md                                     |  74 ++-
 .../apache/pulsar/broker/ServiceConfiguration.java |  81 ++-
 .../pulsar/broker/tls/DefaultBrokerTlsFactory.java |  53 +-
 .../pulsar/broker/tls/TlsFactorySupport.java       |  39 ++
 .../configuration/PulsarConfigurationLoader.java   |  54 ++
 .../pulsar/jetty/tls/JettySslContextFactory.java   |  82 ---
 .../broker/tls/DefaultBrokerTlsPolicyTest.java     |  42 ++
 .../PulsarConfigurationLoaderTest.java             |  64 ++
 .../jetty/tls/JettySslContextFactoryTest.java      | 206 ------
 .../JettySslContextFactoryWithKeyStoreTest.java    | 252 --------
 pulsar-broker/build.gradle.kts                     |   3 +
 .../org/apache/pulsar/broker/PulsarService.java    | 127 +++-
 .../pulsar/broker/namespace/NamespaceService.java  |  17 +-
 .../pulsar/broker/service/BrokerService.java       | 192 +++++-
 .../broker/service/PulsarChannelInitializer.java   | 107 ++--
 .../org/apache/pulsar/broker/web/WebService.java   |  94 ++-
 .../apache/pulsar/compaction/CompactorTool.java    |   4 +-
 .../pulsar/broker/admin/AdminApiTlsAuthTest.java   |  11 +-
 .../admin/BrokerAdminClientTlsFactoryTest.java     |  87 +++
 .../pulsar/broker/service/BrokerServiceTest.java   |   7 +-
 .../service/OneWayReplicatorDeduplicationTest.java |   6 +-
 .../broker/service/ReplicatorTlsFactoryTest.java   | 195 ++++++
 .../tls/BrokerClientPerClusterTlsFactoryTest.java  | 281 +++++++++
 .../pulsar/broker/tls/CountingWebTlsFactory.java   | 107 ++++
 .../apache/pulsar/broker/web/WebServiceTest.java   |   6 +-
 .../AuthenticationTlsHostnameVerificationTest.java |  47 +-
 .../pulsar/client/api/BrokerServiceLookupTest.java |   4 +-
 .../pulsar/client/api/ProxyProtocolTest.java       |   8 +-
 .../client/api/TlsFactoryProducerConsumerTest.java |  96 +++
 .../client/api/v5/V5TlsProducerConsumerTest.java   | 115 ++++
 .../client/impl/AdminApiKeyStoreTlsAuthTest.java   |  33 +-
 .../client/impl/AdminOnlyOAuth2AuthTest.java       |  91 +++
 .../apache/pulsar/client/impl/KeyStoreTlsTest.java | 106 ----
 .../worker/WorkerServerTlsFactoryTest.java         |  98 +++
 .../proxy/ProxyPublishConsumeTlsTest.java          |   7 +-
 .../proxy/WebSocketProxyTlsFactoryTest.java        |  92 +++
 .../pulsar/client/admin/PulsarAdminBuilder.java    |  23 +-
 .../pulsar/common/policies/data/ClusterData.java   |  76 +++
 pulsar-client-admin/build.gradle.kts               |  13 +
 .../pulsar/client/admin/internal/BaseResource.java |  81 ++-
 .../client/admin/internal/ComponentResource.java   |  21 +-
 .../admin/internal/PulsarAdminBuilderImpl.java     |  12 +-
 .../client/admin/internal/PulsarAdminImpl.java     | 160 ++++-
 .../admin/internal/http/AsyncHttpConnector.java    | 265 +++++---
 .../internal/http/AsyncHttpConnectorProvider.java  |  76 ++-
 .../admin/internal/http/TlsFactoryOwnership.java   | 114 ++++
 .../internal/AdminOAuth2IdpTlsBindingTest.java     | 110 ++++
 .../internal/AdminOAuth2IdpTlsEndToEndTest.java    | 238 +++++++
 .../internal/AdminTlsFactoryLifecycleTest.java     | 188 ++++++
 .../internal/http/AsyncHttpConnectorTest.java      |  28 +
 pulsar-client-api-v5/build.gradle.kts              |   5 +
 .../pulsar/client/api/v5/PulsarClientBuilder.java  |  49 +-
 .../client/api/v5/PulsarClientException.java       |  33 +
 .../auth/{package-info.java => AuthChallenge.java} |  20 +-
 .../pulsar/client/api/v5/auth/Authentication.java  |  96 ++-
 .../api/v5/auth/AuthenticationCallContext.java     |  69 ++
 .../client/api/v5/auth/AuthenticationData.java     |  99 ---
 .../client/api/v5/auth/AuthenticationFactory.java  |  16 +-
 .../api/v5/auth/AuthenticationInitContext.java     |  85 +++
 .../api/v5/auth/BinaryAuthChallengeHandler.java    |  49 ++
 .../pulsar/client/api/v5/auth/BinaryAuthData.java  |  40 ++
 .../client/api/v5/auth/BinaryAuthDataProvider.java |  49 ++
 .../client/api/v5/auth/ChallengeResponse.java      |  39 ++
 .../client/api/v5/auth/HttpAuthCallContext.java    |  68 ++
 .../api/v5/auth/HttpAuthChallengeHandler.java      |  49 ++
 .../pulsar/client/api/v5/auth/HttpAuthHeaders.java | 118 ++++
 .../api/v5/auth/HttpAuthHeadersProvider.java       |  42 ++
 .../api/v5/auth/SinglePassAuthentication.java}     |  16 +-
 .../pulsar/client/api/v5/auth/package-info.java    |  57 +-
 .../pulsar/client/api/v5/config/TlsPolicy.java     | 177 ------
 .../pulsar/client/api/v5/config/package-info.java  |   6 +-
 .../v5/internal/ClientAuthenticationServices.java  |  78 +++
 .../ClientAuthenticationServicesAware.java         |  47 ++
 .../api/v5/internal/PulsarClientProvider.java      |   2 +-
 .../org/apache/pulsar/client/api/v5/Examples.java  |   2 +-
 .../apache/pulsar/client/api/ClientBuilder.java    |  19 +-
 .../api/internal/AsyncAuthenticationDriver.java    |  96 +++
 .../pulsar/client/api/internal/package-info.java   |  13 +-
 .../org/apache/pulsar/admin/cli/CmdClusters.java   |  31 +-
 .../apache/pulsar/client/cli/PulsarClientTool.java |  63 +-
 .../apache/pulsar/admin/cli/TestCmdClusters.java   |  21 +
 .../pulsar/client/cli/PulsarClientToolTest.java    | 162 +++++
 pulsar-client-v5/build.gradle.kts                  |   2 +
 .../client/impl/v5/AuthenticationAdapter.java      | 202 ------
 .../client/impl/v5/PulsarClientBuilderV5.java      | 306 ++++++++-
 .../client/impl/v5/PulsarClientProviderV5.java     |  24 +-
 .../client/impl/v5/auth/SimpleAuthCallContext.java |  57 ++
 .../client/impl/v5/auth/SimpleAuthInitContext.java |  80 +++
 .../impl/v5/auth/V5ToV4AuthenticationAdapter.java  | 351 +++++++++++
 .../pulsar/client/impl/v5/auth}/package-info.java  |  14 +-
 .../client/impl/v5/AuthenticationAdapterTest.java  |  62 --
 .../v5/CredentialOffloadThroughV5BuilderTest.java  | 241 +++++++
 .../client/impl/v5/PulsarClientBuilderV5Test.java  | 356 ++++++++++-
 .../client/impl/v5/V5AuthenticationLoaderTest.java | 171 +++++
 .../v5/auth/V5ToV4AuthenticationAdapterTest.java   | 251 ++++++++
 pulsar-client/build.gradle.kts                     |  19 +
 .../pulsar/client/impl/ClientBuilderImpl.java      |  15 +-
 .../org/apache/pulsar/client/impl/ClientCnx.java   | 311 ++++++++-
 .../apache/pulsar/client/impl/ConnectionPool.java  |  18 +
 .../org/apache/pulsar/client/impl/HttpClient.java  | 280 ++++++---
 .../pulsar/client/impl/HttpLookupService.java      |  10 +-
 .../client/impl/PulsarChannelInitializer.java      | 197 +++---
 .../pulsar/client/impl/PulsarClientImpl.java       | 295 ++++++++-
 .../client/impl/auth/AuthenticationBasic.java      |  31 +-
 .../client/impl/auth/AuthenticationDataTls.java    |  18 +-
 .../impl/auth/AuthenticationKeyStoreTls.java       |  11 +
 .../client/impl/auth/AuthenticationToken.java      | 110 ++--
 .../impl/auth/oauth2/AuthenticationOAuth2.java     |  60 +-
 .../pulsar/client/impl/auth/oauth2/FlowBase.java   | 190 +++---
 .../impl/auth/oauth2/OAuth2AuthenticationV5.java   | 112 ++++
 .../oauth2/StandaloneOAuth2HttpClientFactory.java  | 175 ++++++
 .../oauth2/protocol/DefaultMetadataResolver.java   |  30 +-
 .../impl/auth/oauth2/protocol/TokenClient.java     |  37 +-
 .../auth/v5/AsyncHttpAuthenticationProvider.java   |  47 ++
 .../pulsar/client/impl/auth/v5/AuthMetrics.java    | 127 ++++
 .../client/impl/auth/v5/BasicAuthenticationV5.java |  99 +++
 .../impl/auth/v5/BinaryAuthenticationExchange.java | 201 ++++++
 .../v5/DefaultClientAuthenticationServices.java    |  46 ++
 .../client/impl/auth/v5/FrameworkHttpClient.java   | 184 ++++++
 .../impl/auth/v5/FrameworkHttpClientFactory.java   | 282 +++++++++
 .../impl/auth/v5/HttpAuthCallContextImpl.java      |  85 +++
 .../impl/auth/v5/HttpAuthenticationDriver.java     | 277 ++++++++
 .../impl/auth/v5/HttpChallengeTransport.java       |  68 ++
 .../auth/v5/LegacyV4AuthenticationAdapter.java     | 436 +++++++++++++
 .../client/impl/auth/v5/TlsAuthentication.java     |  64 ++
 .../client/impl/auth/v5/TokenAuthenticationV5.java | 192 ++++++
 .../pulsar/client/impl/auth/v5/V5AuthContexts.java | 211 +++++++
 .../impl/auth/v5/V5AuthenticationLoader.java       | 132 ++++
 .../impl/auth/v5/V5BinaryAuthenticationDriver.java | 165 +++++
 .../pulsar/client/impl/auth/v5}/package-info.java  |  15 +-
 .../client/impl/conf/ClientConfigurationData.java  |  57 +-
 .../client/impl/conf/ConfigurationDataUtils.java   |  48 ++
 .../client/impl/tls/ClientTlsFactorySupport.java   | 695 +++++++++++++++++++++
 .../pulsar/client/impl}/tls/package-info.java      |   7 +-
 .../util/PulsarHttpAsyncSslEngineFactory.java      |  69 --
 .../pulsar/client/impl/ClientCnxAsyncAuthTest.java | 545 ++++++++++++++++
 .../impl/ClientCnxRequestTimeoutQueueTest.java     |   7 +
 .../ClientTlsFactoryRebuildOnFailoverTest.java     |  92 +++
 .../apache/pulsar/client/impl/HttpClientTest.java  |  78 +++
 .../impl/OAuth2IdpTlsPlaintextBrokerTest.java      | 157 +++++
 ...AuthenticationTokenSerializationCompatTest.java |  90 +++
 .../oauth2/AuthenticationFactoryOAuth2Test.java    |  28 +-
 .../impl/auth/oauth2/AuthenticationOAuth2Test.java |  16 +-
 .../impl/auth/oauth2/OAuth2IdpTlsFoldTest.java     |  86 +++
 .../oauth2/OAuth2IdpTlsFrameworkClientTest.java    | 214 +++++++
 .../impl/auth/oauth2/OAuth2MockHttpClient.java     |  49 --
 .../StandaloneOAuth2HttpClientFactoryTest.java     | 174 ++++++
 .../impl/auth/oauth2/TlsClientAuthFlowTest.java    |   8 +-
 .../impl/auth/oauth2/protocol/TokenClientTest.java | 103 +--
 .../impl/auth/v5/AuthInitializationRetryTest.java  |  95 +++
 .../client/impl/auth/v5/AuthMetricsTest.java       | 149 +++++
 .../auth/v5/BinaryAuthenticationExchangeTest.java  | 129 ++++
 .../client/impl/auth/v5/CredentialOffloadTest.java | 189 ++++++
 .../auth/v5/FrameworkHttpClientFactoryTest.java    | 251 ++++++++
 .../impl/auth/v5/HttpAuthenticationDriverTest.java | 303 +++++++++
 .../auth/v5/LegacyV4AuthenticationAdapterTest.java | 348 +++++++++++
 .../client/impl/auth/v5/TlsAuthenticationTest.java |  42 ++
 .../impl/conf/ConfigurationDataUtilsTest.java      |  53 ++
 .../impl/tls/BrokerClientPurposeFactoryTest.java   | 172 +++++
 .../impl/tls/ClientDefaultPolicyStoreTypeTest.java | 121 ++++
 .../client/impl/tls/ClientEngineProviderTest.java  |  60 ++
 .../tls/ClientTlsFactoryParamsDeliveryTest.java    | 316 ++++++++++
 .../common/policies/data/ClusterDataImpl.java      |  58 +-
 .../tls/impl/FileBasedTlsFactorySettings.java      |  22 +
 .../common/tls/impl/TlsContextAcquisition.java     |  24 +
 .../org/apache/pulsar/common/tls/package-info.java |  17 +
 .../common/util/FileModifiedTimeUpdaterTest.java   |  24 -
 .../pulsar/functions/worker/WorkerConfig.java      |  57 +-
 .../worker/PulsarWorkerOpenTelemetry.java          |   9 +-
 .../pulsar/functions/worker/WorkerUtils.java       |  56 ++
 .../pulsar/functions/worker/rest/WorkerServer.java | 137 ++--
 pulsar-proxy/build.gradle.kts                      |   8 +
 .../pulsar/proxy/server/AdminProxyHandler.java     | 153 ++---
 .../pulsar/proxy/server/DirectProxyHandler.java    |  76 +--
 .../apache/pulsar/proxy/server/ProxyClientCnx.java |   6 +-
 .../pulsar/proxy/server/ProxyConfiguration.java    |  72 ++-
 .../pulsar/proxy/server/ProxyConnection.java       |  39 +-
 .../apache/pulsar/proxy/server/ProxyService.java   | 120 +++-
 .../pulsar/proxy/server/ProxyServiceStarter.java   |  11 +-
 .../pulsar/proxy/server/ProxyTlsFactories.java     | 169 +++++
 .../proxy/server/ServiceChannelInitializer.java    | 104 +--
 .../org/apache/pulsar/proxy/server/WebServer.java  | 122 ++--
 .../proxy/stats/PulsarProxyOpenTelemetry.java      |   9 +-
 .../AuthedAdminProxyHandlerNewTlsPathTest.java     |  39 ++
 .../proxy/server/AuthedAdminProxyHandlerTest.java  |  13 +
 .../server/ProxyBrokerClientTlsOrderingTest.java   | 196 ++++++
 .../server/ProxyKeyStoreTlsTransportTest.java      |   6 +
 .../proxy/server/ProxyKeyStoreTlsWithAuthTest.java |   5 +
 .../server/ProxyKeyStoreTlsWithoutAuthTest.java    |   5 +
 .../pulsar/proxy/server/ProxyMutualTlsTest.java    |   5 +
 .../proxy/server/ProxyServiceTlsStarterTest.java   |   5 +
 .../org/apache/pulsar/proxy/server/ProxyTest.java  |   4 +-
 .../pulsar/proxy/server/ProxyTlsFactoriesTest.java |  68 ++
 .../proxy/server/ProxyTlsFactoryMetricsTest.java   | 143 +++++
 ...{ProxyTlsTest.java => ProxyTlsFactoryTest.java} |  58 +-
 .../apache/pulsar/proxy/server/ProxyTlsTest.java   |   5 +
 .../proxy/server/ProxyWithAuthorizationTest.java   |  71 ++-
 .../server/ProxyWithoutServiceDiscoveryTest.java   |   5 +
 .../server/SslContextFallbackSynthesisTest.java    | 229 +++++++
 .../proxy/server/SslContextOnlyTlsFactory.java     | 155 +++++
 .../apache/pulsar/testclient/PerfClientUtils.java  |  62 +-
 .../testclient/PerformanceBaseArguments.java       |   9 -
 .../pulsar/testclient/PerfClientUtilsTest.java     |  30 +
 pulsar-websocket/build.gradle.kts                  |   3 +
 .../apache/pulsar/websocket/WebSocketService.java  |  28 +
 .../pulsar/websocket/service/ProxyServer.java      | 162 +++--
 .../service/WebSocketProxyConfiguration.java       |  48 +-
 .../websocket/WebSocketProxyConfigurationTest.java |   3 +-
 tests/certificate-authority/ec/ca.cert.srl         |   2 +-
 tests/certificate-authority/ec/jks/ca.cert.srl     |   2 +-
 .../ec/jks/key_store_generation.txt                |  17 +-
 .../ec/jks/server.keystore.jks                     | Bin 2004 -> 2164 bytes
 .../ec/jks/server.signed.cert.pem                  |  24 +-
 tests/certificate-authority/ec/server.cert.pem     |  16 +-
 tests/certificate-authority/ec/server.conf         |   1 +
 tests/certificate-authority/ec/server.csr.pem      |   6 +-
 tests/certificate-authority/generate_keystore.sh   |  10 +-
 .../certificate-authority/jks/broker.keystore.jks  | Bin 2254 -> 2281 bytes
 .../jks/broker.truststore.jks                      | Bin 969 -> 997 bytes
 .../jks/broker.truststore.nopassword.jks           | Bin 969 -> 997 bytes
 .../jks/broker.truststore.pem                      | 113 ++--
 .../integration/topologies/PulsarCluster.java      |   9 +
 228 files changed, 17540 insertions(+), 2964 deletions(-)

diff --git a/conf/broker.conf b/conf/broker.conf
index d1ab312300c1..8fbb45ea638e 100644
--- a/conf/broker.conf
+++ b/conf/broker.conf
@@ -857,9 +857,20 @@ proxyRoles=
 # else it just accepts the originalPrincipal and authorizes it (if required).
 authenticateOriginalAuthData=false
 
-# Tls cert refresh duration in seconds (set 0 to check on every new connection)
+# Tls cert refresh duration in seconds. Set 0 to disable the background rotation check, so the
+# TLS material loaded at startup is kept until restart.
 tlsCertRefreshCheckDurationSec=300
 
+# PIP-478 TLS factory (PulsarTlsFactory) class name for the broker's server-side TLS (binary listener and
+# web server; purposes BROKER/PROXY/WEB). An empty value or the literal 'default' selects the built-in
+# DefaultBrokerTlsFactory composed from these tls* settings, otherwise the named class is instantiated via
+# its public no-arg constructor. This is the only server TLS path; the removed PIP-337 sslFactoryPlugin
+# keys are rejected at startup when set to a non-default value.
+tlsFactoryClassName=
+
+# PIP-478 configuration parameters for tlsFactoryClassName (JSON object or comma-separated key=value list)
+tlsFactoryConfig=
+
 # Path for the TLS certificate file
 tlsCertificateFilePath=
 
@@ -878,8 +889,9 @@ tlsTrustCertsFilePath=
 # though the cert will not be used for client authentication.
 tlsAllowInsecureConnection=false
 
-# Whether the hostname is validated when the broker creates a TLS connection with other brokers
-tlsHostnameVerificationEnabled=false
+# Whether the hostname is validated when the broker creates a TLS connection with other brokers.
+# Enabled by default since Pulsar 5.0 (PIP-478).
+tlsHostnameVerificationEnabled=true
 
 # Specify the tls protocols the broker will use to negotiate during TLS handshake
 # (a comma-separated list of protocol names).
@@ -909,10 +921,22 @@ tlsRequireTrustedClientCertOnConnect=false
 # native engine.
 tlsProvider=
 
+# PIP-478: name of a JSSE (SSLContext) java.security.Provider used to build the broker's server-side
+# (listener/web) TLS SSLContext, e.g. BCJSSE for FIPS (with the BC-FIPS crypto provider registered
+# separately). A distinct axis from tlsProvider (the JDK-vs-OpenSSL engine switch): when set, the default
+# factory builds the JDK engine with this provider as the SSLContext provider, overriding the engine
+# choice. Resolved via the ServiceLoader mechanism with a fallback to an already-registered provider,
+# failing loudly when unresolvable.
+# jsseProvider=
+
 # Specify the TLS provider for the web service: SunJSSE, Conscrypt and etc.
 # This names a JSSE (SSLContext) security provider for the Jetty-based web service, which has no native TLS
 # engine, so Netty engine values (JDK, OPENSSL, OPENSSL_REFCNT) are not valid provider names here.
-webServiceTlsProvider=Conscrypt
+# Leave unset (the default) to use Conscrypt when it is available on this platform, else the JVM's default
+# provider; a configured name is pinned and startup fails if it cannot be resolved. Conscrypt ships native
+# libraries for x86_64 and, since 2.6.1, aarch64 — but not for every platform, which is why the default
+# falls back instead of failing where it cannot load; pinning it explicitly there does fail.
+webServiceTlsProvider=
 
 ### --- KeyStore TLS config variables --- ###
 ## Note that some of the above TLS configs also apply to the KeyStore TLS configuration.
@@ -947,6 +971,14 @@ brokerClientTlsEnabledWithKeyStore=false
 # The TLS Provider used by internal client to authenticate with other Pulsar brokers
 brokerClientSslProvider=
 
+# PIP-478: name of a JSSE (SSLContext) java.security.Provider used to build the TLS SSLContext for the
+# broker's own outbound (broker-to-broker / replication) client connections, e.g. BCJSSE for FIPS (with
+# the BC-FIPS crypto provider registered separately). When set, the default factory builds the JDK engine
+# with this provider as the SSLContext provider, overriding the engine choice. Resolved via the
+# ServiceLoader mechanism with a fallback to an already-registered provider, failing loudly when
+# unresolvable.
+# brokerClientJsseProvider=
+
 # TLS trusted certificate file for internal client,
 # used by the internal client to authenticate with Pulsar brokers
 brokerClientTrustCertsFilePath=
@@ -997,6 +1029,16 @@ brokerClientTlsCiphers=
 # brokerClientTlsProtocols=TLSv1.3,TLSv1.2
 brokerClientTlsProtocols=
 
+# PIP-478 TLS factory (PulsarTlsFactory) class name for the broker's own outbound (broker-to-broker) client
+# connections (purpose BROKER_CLIENT). An empty value or the literal 'default' selects the built-in
+# default factory composed from the brokerClient tls* settings, otherwise the named class is instantiated
+# via its public no-arg constructor. This is the only outbound-client TLS path; the removed PIP-337
+# brokerClientSslFactoryPlugin keys are rejected at startup when set to a non-default value.
+brokerClientTlsFactoryClassName=
+
+# PIP-478 configuration parameters for brokerClientTlsFactoryClassName (JSON object or key=value list)
+brokerClientTlsFactoryConfig=
+
 # You can add extra configuration options for the Pulsar Client and the Pulsar Admin Client
 # by prefixing them with "brokerClient_". These configurations are applied after hard coded configuration
 # and before the above brokerClient configurations named above.
diff --git a/conf/client.conf b/conf/client.conf
index 5942bdb75cb8..df44375cd2bd 100644
--- a/conf/client.conf
+++ b/conf/client.conf
@@ -46,9 +46,10 @@ authParams=
 # authority.
 tlsAllowInsecureConnection=false
 
-# Whether server hostname must match the common name of the certificate
-# the server is using.
-tlsEnableHostnameVerification=false
+# Whether the server hostname is verified against the subject alternative names (SANs) of the
+# certificate the server is using (RFC 2818/6125; CN matching is not supported).
+# Enabled by default since Pulsar 5.0 (PIP-478).
+tlsEnableHostnameVerification=true
 
 # Path for the trusted TLS certificate file.
 # This cert is used to verify that any cert presented by a server
@@ -88,6 +89,13 @@ tlsKeyStorePassword=
 # When TLS authentication with KeyStore is used, available options can be SunJSSE, Conscrypt and so on.
 webserviceTlsProvider=
 
+# PIP-478: name of a JSSE (SSLContext) java.security.Provider used to build the client's TLS SSLContext,
+# e.g. BCJSSE for FIPS (with the BC-FIPS crypto provider registered separately). A distinct axis from the
+# JDK-vs-OpenSSL engine selection (sslProvider): when set, the TLS context is built on the JDK engine with
+# this provider as the SSLContext provider, overriding the engine choice. Resolved via the ServiceLoader
+# mechanism with a fallback to an already-registered provider, failing loudly when unresolvable.
+# jsseProvider=
+
 #Proxy-server URL to which to connect
 proxyServiceUrl=
 
diff --git a/conf/functions_worker.yml b/conf/functions_worker.yml
index 794cfba1d519..e6dfbc35255a 100644
--- a/conf/functions_worker.yml
+++ b/conf/functions_worker.yml
@@ -348,10 +348,28 @@ tlsKeyFilePath:
 tlsTrustCertsFilePath:
 # Accept untrusted TLS certificate from client
 tlsAllowInsecureConnection: false
-# Whether server hostname must match the common name of the certificate
-tlsEnableHostnameVerification: false
-# Tls cert refresh duration in seconds (set 0 to check on every new connection)
+# Whether the server hostname is verified against the subject alternative names (SANs) of the
+# server certificate (RFC 2818/6125; CN matching is not supported).
+# Enabled by default since Pulsar 5.0 (PIP-478).
+tlsEnableHostnameVerification: true
+# Tls cert refresh duration in seconds. Set 0 to disable the background rotation check, so the
+# TLS material loaded at startup is kept until restart.
 tlsCertRefreshCheckDurationSec: 300
+# PIP-478 TLS factory (PulsarTlsFactory) class name for the functions-worker web server TLS (purpose WEB).
+# When set, the new PIP-478 TLS SPI is used instead of the built-in file-based TLS loading: an empty value
+# or the literal 'default' selects the built-in default factory composed from these tls* settings, otherwise
+# the named class is instantiated via its public no-arg constructor.
+tlsFactoryClassName: ""
+# PIP-478 configuration parameters for tlsFactoryClassName (JSON object or comma-separated key=value list)
+tlsFactoryConfig: ""
+# PIP-478 TLS factory (PulsarTlsFactory) class name for the functions-worker's own outbound
+# (worker-to-broker) client connections (purpose BROKER_CLIENT). An empty value or the literal 'default'
+# selects the built-in default factory composed from the broker-client tls* settings, otherwise the named
+# class is instantiated via its public no-arg constructor.
+brokerClientTlsFactoryClassName: ""
+# PIP-478 configuration parameters for brokerClientTlsFactoryClassName (JSON object or comma-separated
+# key=value list)
+brokerClientTlsFactoryConfig: ""
 # Whether client certificates are required for TLS. Connections are rejected if the client
 # certificate isn't trusted.
 tlsRequireTrustedClientCertOnConnect: false
@@ -360,7 +378,19 @@ tlsRequireTrustedClientCertOnConnect: false
 ## Note that some of the above TLS configs also apply to the KeyStore TLS configuration.
 
 # Specify the TLS provider for the web service: SunJSSE, Conscrypt and etc.
-tlsProvider: Conscrypt
+# Leave unset (the default) to use Conscrypt when it is available on this platform, else the JVM's
+# default provider; a configured name is pinned and startup fails if it cannot be resolved. Conscrypt
+# ships native libraries for x86_64 and, since 2.6.1, aarch64 — but not for every platform, which is why
+# the default falls back instead of failing where it cannot load; pinning it explicitly there does fail.
+tlsProvider:
+
+# PIP-478: name of a JSSE (SSLContext) java.security.Provider used to build the function worker's
+# web-service TLS SSLContext, e.g. BCJSSE for FIPS (with the BC-FIPS crypto provider registered
+# separately). A distinct axis from tlsProvider (the JDK-vs-OpenSSL engine switch): when set, the
+# default factory builds the JDK engine with this provider as the SSLContext provider, overriding the
+# engine choice. Resolved via the ServiceLoader mechanism with a fallback to an already-registered
+# provider, failing loudly when unresolvable.
+# jsseProvider:
 
 # Enable TLS with KeyStore type configuration in function worker.
 tlsEnabledWithKeyStore: false
diff --git a/conf/proxy.conf b/conf/proxy.conf
index fdc7fce72061..fda1c872712d 100644
--- a/conf/proxy.conf
+++ b/conf/proxy.conf
@@ -119,11 +119,23 @@ numAcceptorThreads=
 # native engine.
 tlsProvider=
 
+# PIP-478: name of a JSSE (SSLContext) java.security.Provider used to build the proxy's server-side
+# (binary front-end / web) TLS SSLContext, e.g. BCJSSE for FIPS (with the BC-FIPS crypto provider
+# registered separately). A distinct axis from tlsProvider (the JDK-vs-OpenSSL engine switch): when set,
+# the default factory builds the JDK engine with this provider as the SSLContext provider, overriding the
+# engine choice. Resolved via the ServiceLoader mechanism with a fallback to an already-registered
+# provider, failing loudly when unresolvable.
+# jsseProvider=
+
 # Specify the TLS provider for the web service, available values can be SunJSSE, Conscrypt and etc.
 # This names a JSSE (SSLContext) security provider for a Jetty-based web service, which has no native TLS
 # engine, so Netty engine values (JDK, OPENSSL, OPENSSL_REFCNT) are not valid provider names here.
 # Note that the proxy's own HTTPS listener reads tlsProvider rather than this setting.
-webServiceTlsProvider=Conscrypt
+# Leave unset (the default) to use Conscrypt when it is available on this platform, else the JVM's default
+# provider; a configured name is pinned and startup fails if it cannot be resolved. Conscrypt ships native
+# libraries for x86_64 and, since 2.6.1, aarch64 — but not for every platform, which is why the default
+# falls back instead of failing where it cannot load; pinning it explicitly there does fail.
+webServiceTlsProvider=
 
 # Enable TLS with KeyStore type configuration in proxy.
 tlsEnabledWithKeyStore=false
@@ -218,9 +230,37 @@ brokerClientTrustCertsFilePath=
 # Whether TLS is enabled when communicating with Pulsar brokers
 tlsEnabledWithBroker=false
 
-# Tls cert refresh duration in seconds (set 0 to check on every new connection)
+# Tls cert refresh duration in seconds. Set 0 to disable the background rotation check, so the
+# TLS material loaded at startup is kept until restart.
 tlsCertRefreshCheckDurationSec=300
 
+# PIP-478 TLS factory (PulsarTlsFactory) class name for the proxy's server-side TLS (binary front-end and
+# web server; purposes PROXY/WEB). An empty value or the literal 'default' selects the built-in default
+# factory composed from these tls* settings, otherwise the named class is instantiated via its public
+# no-arg constructor. This is the only server TLS path; the removed PIP-337 sslFactoryPlugin keys are
+# rejected at startup when set to a non-default value.
+tlsFactoryClassName=
+
+# PIP-478 configuration parameters for tlsFactoryClassName (JSON object or comma-separated key=value list)
+tlsFactoryConfig=
+
+# PIP-478 TLS factory (PulsarTlsFactory) class name for the proxy's own outbound (proxy-to-broker) client
+# connections (purpose BROKER_CLIENT). An empty value or the literal 'default' selects the built-in default
+# factory composed from the brokerClient tls* settings, otherwise the named class is instantiated via its
+# public no-arg constructor. This is the only outbound-client TLS path; the removed PIP-337
+# brokerClientSslFactoryPlugin keys are rejected at startup when set to a non-default value.
+brokerClientTlsFactoryClassName=
+
+# PIP-478 configuration parameters for brokerClientTlsFactoryClassName (JSON object or key=value list)
+brokerClientTlsFactoryConfig=
+
+# PIP-478: name of a JSSE (SSLContext) java.security.Provider used to build the TLS SSLContext for the
+# proxy's own outbound (proxy-to-broker) client connections, e.g. BCJSSE for FIPS (with the BC-FIPS
+# crypto provider registered separately). When set, the default factory builds the JDK engine with this
+# provider as the SSLContext provider, overriding the engine choice. Resolved via the ServiceLoader
+# mechanism with a fallback to an already-registered provider, failing loudly when unresolvable.
+# brokerClientJsseProvider=
+
 # You can add extra configuration options for the Pulsar Client
 # by prefixing them with "brokerClient_". These configurations are applied after hard coded configuration
 # and before the above brokerClient configurations named above.
@@ -283,8 +323,9 @@ tlsTrustCertsFilePath=
 # though the cert will not be used for client authentication.
 tlsAllowInsecureConnection=false
 
-# Whether the hostname is validated when the proxy creates a TLS connection with brokers
-tlsHostnameVerificationEnabled=false
+# Whether the hostname is validated when the proxy creates a TLS connection with brokers.
+# Enabled by default since Pulsar 5.0 (PIP-478).
+tlsHostnameVerificationEnabled=true
 
 # Specify the tls protocols the broker will use to negotiate during TLS handshake
 # (a comma-separated list of protocol names).
diff --git a/conf/standalone.conf b/conf/standalone.conf
index eed7af99e5ad..5ffe609cf711 100644
--- a/conf/standalone.conf
+++ b/conf/standalone.conf
@@ -485,9 +485,20 @@ metadataStoreSerDesThreads=1
 # Deprecated - Use webServicePortTls and brokerServicePortTls instead
 tlsEnabled=false
 
-# Tls cert refresh duration in seconds (set 0 to check on every new connection)
+# Tls cert refresh duration in seconds. Set 0 to disable the background rotation check, so the
+# TLS material loaded at startup is kept until restart.
 tlsCertRefreshCheckDurationSec=300
 
+# PIP-478 TLS factory (PulsarTlsFactory) class name for the embedded broker's server-side TLS (binary
+# listener and web server; purposes BROKER/PROXY/WEB). An empty value or the literal 'default' selects the
+# built-in DefaultBrokerTlsFactory composed from these tls* settings, otherwise the named class is
+# instantiated via its public no-arg constructor. This is the only server TLS path; the removed PIP-337
+# sslFactoryPlugin keys are rejected at startup when set to a non-default value.
+tlsFactoryClassName=
+
+# PIP-478 configuration parameters for tlsFactoryClassName (JSON object or comma-separated key=value list)
+tlsFactoryConfig=
+
 # Path for the TLS certificate file
 tlsCertificateFilePath=
 
@@ -534,10 +545,22 @@ tlsRequireTrustedClientCertOnConnect=false
 # native engine.
 tlsProvider=
 
+# PIP-478: name of a JSSE (SSLContext) java.security.Provider used to build the embedded broker's
+# server-side (listener/web) TLS SSLContext, e.g. BCJSSE for FIPS (with the BC-FIPS crypto provider
+# registered separately). A distinct axis from tlsProvider (the JDK-vs-OpenSSL engine switch): when set,
+# the default factory builds the JDK engine with this provider as the SSLContext provider, overriding the
+# engine choice. Resolved via the ServiceLoader mechanism with a fallback to an already-registered
+# provider, failing loudly when unresolvable.
+# jsseProvider=
+
 # Specify the TLS provider for the web service: SunJSSE, Conscrypt and etc.
 # This names a JSSE (SSLContext) security provider for the Jetty-based web service, which has no native TLS
 # engine, so Netty engine values (JDK, OPENSSL, OPENSSL_REFCNT) are not valid provider names here.
-webServiceTlsProvider=Conscrypt
+# Leave unset (the default) to use Conscrypt when it is available on this platform, else the JVM's default
+# provider; a configured name is pinned and startup fails if it cannot be resolved. Conscrypt ships native
+# libraries for x86_64 and, since 2.6.1, aarch64 — but not for every platform, which is why the default
+# falls back instead of failing where it cannot load; pinning it explicitly there does fail.
+webServiceTlsProvider=
 
 ### --- KeyStore TLS config variables --- ###
 # Enable TLS with KeyStore type configuration in broker.
@@ -570,6 +593,14 @@ brokerClientTlsEnabledWithKeyStore=false
 # The TLS Provider used by internal client to authenticate with other Pulsar brokers
 brokerClientSslProvider=
 
+# PIP-478: name of a JSSE (SSLContext) java.security.Provider used to build the TLS SSLContext for the
+# embedded broker's own outbound (broker-to-broker) client connections, e.g. BCJSSE for FIPS (with the
+# BC-FIPS crypto provider registered separately). When set, the default factory builds the JDK engine
+# with this provider as the SSLContext provider, overriding the engine choice. Resolved via the
+# ServiceLoader mechanism with a fallback to an already-registered provider, failing loudly when
+# unresolvable.
+# brokerClientJsseProvider=
+
 # TLS trusted certificate file for internal client,
 # used by the internal client to authenticate with Pulsar brokers
 brokerClientTrustCertsFilePath=
@@ -620,6 +651,16 @@ brokerClientTlsCiphers=
 # brokerClientTlsProtocols=TLSv1.3,TLSv1.2
 brokerClientTlsProtocols=
 
+# PIP-478 TLS factory (PulsarTlsFactory) class name for the embedded broker's own outbound
+# (broker-to-broker) client connections (purpose BROKER_CLIENT). An empty value or the literal 'default'
+# selects the built-in default factory composed from the brokerClient tls* settings, otherwise the named
+# class is instantiated via its public no-arg constructor. This is the only outbound-client TLS path; the
+# removed PIP-337 brokerClientSslFactoryPlugin keys are rejected at startup when set to a non-default value.
+brokerClientTlsFactoryClassName=
+
+# PIP-478 configuration parameters for brokerClientTlsFactoryClassName (JSON object or key=value list)
+brokerClientTlsFactoryConfig=
+
 # Enable or disable system topic
 systemTopicEnabled=true
 
diff --git a/conf/websocket.conf b/conf/websocket.conf
index 91f7f7d4c23b..f7cca9f77dca 100644
--- a/conf/websocket.conf
+++ b/conf/websocket.conf
@@ -122,7 +122,8 @@ brokerClientAuthenticationParameters=
 brokerClientTrustCertsFilePath=
 
 # Whether the hostname is validated when connecting to the broker.
-tlsHostnameVerificationEnabled=false
+# Enabled by default since Pulsar 5.0 (PIP-478).
+tlsHostnameVerificationEnabled=true
 
 # You can add extra configuration options for the Pulsar Client
 # by prefixing them with "brokerClient_". These configurations are applied after hard coded configuration
@@ -153,11 +154,35 @@ tlsTrustCertsFilePath=
 # Reject the Connection if the Client Certificate is not trusted.
 tlsRequireTrustedClientCertOnConnect=false
 
-# Tls cert refresh duration in seconds (set 0 to check on every new connection)
+# Tls cert refresh duration in seconds. Set 0 to disable the background rotation check, so the
+# TLS material loaded at startup is kept until restart.
 tlsCertRefreshCheckDurationSec=300
 
+# PIP-478 TLS factory (PulsarTlsFactory) class name for the WebSocket proxy's web server TLS (purpose WEB).
+# When set, the new PIP-478 TLS SPI is used instead of the built-in file-based TLS loading: an empty value
+# or the literal 'default' selects the built-in default factory composed from these tls* settings, otherwise
+# the named class is instantiated via its public no-arg constructor.
+tlsFactoryClassName=
+
+# PIP-478 configuration parameters for tlsFactoryClassName (JSON object or comma-separated key=value list)
+tlsFactoryConfig=
+
+# PIP-478 TLS factory (PulsarTlsFactory) class name for the WebSocket proxy's own outbound
+# (websocket-to-broker) client connections (purpose BROKER_CLIENT). An empty value or the literal 'default'
+# selects the built-in default factory composed from the broker-client tls* settings, otherwise the named
+# class is instantiated via its public no-arg constructor.
+brokerClientTlsFactoryClassName=
+
+# PIP-478 configuration parameters for brokerClientTlsFactoryClassName (JSON object or comma-separated
+# key=value list)
+brokerClientTlsFactoryConfig=
+
 # Specify the TLS provider for the WebSocket: SunJSSE, Conscrypt and etc.
-tlsProvider=Conscrypt
+# Leave unset (the default) to use Conscrypt when it is available on this platform, else the JVM's
+# default provider; a configured name is pinned and startup fails if it cannot be resolved. Conscrypt
+# ships native libraries for x86_64 and, since 2.6.1, aarch64 — but not for every platform, which is why
+# the default falls back instead of failing where it cannot load; pinning it explicitly there does fail.
+tlsProvider=
 
 # Enable TLS with KeyStore type configuration in WebSocket.
 tlsEnabledWithKeyStore=false
diff --git a/pip/pip-478.md b/pip/pip-478.md
index 2c7a95c5e262..9419f67750f1 100644
--- a/pip/pip-478.md
+++ b/pip/pip-478.md
@@ -273,7 +273,11 @@ public interface Authentication extends AutoCloseable {
     /**
      * Initialization step. Called once by the framework with runtime services
      * after configuration. May do I/O; the returned future completes when the
-     * implementation is ready to serve credentials.
+     * implementation is ready to serve credentials. MUST NOT block the calling
+     * thread: the framework may call this from an I/O thread and never waits
+     * for the future there. Start the I/O and return promptly, off-loading
+     * blocking work to ctx.blockingExecutor(). A failed future is not cached —
+     * the next use retries.
      */
     CompletableFuture<Void> initializeAsync(AuthenticationInitContext ctx);
 
@@ -521,7 +525,7 @@ public interface AuthenticationInitContext {
 }
 ```
 
-The lifecycle is: the framework constructs a single `AuthenticationInitContext` per `PulsarClient`, calls `initializeAsync(ctx)` once when the client is built, and the implementation may retain references for the lifetime of the client. `Authentication.close()` releases any resources the implementation acquired; the framework owns and closes the shared services (HTTP clients, scheduler, blocking executor). Per-call `AuthenticationCallContext` instances are cheap to allocate.
+The lifecycle is: the framework constructs a single `AuthenticationInitContext` per `PulsarClient` and calls `initializeAsync(ctx)` once, and the implementation may retain references for the lifetime of the client. When the plugin is configured through the v5 builder it is initialized **eagerly at client build**, on the application thread, so a misconfiguration fails the build. The framework's own drivers — which also carry a v4 built-in plugin that delegates to a v5 body, where there is [...]
 
 ### `AuthenticationCallContext` / `HttpAuthCallContext`
 
@@ -873,6 +877,10 @@ The tier-1 case is one expression — `builder.tlsPolicy(TlsPolicy.pem(trust, ce
 
 **Not enabling the transport is not the same as being ignored: the client composes a TLS factory whenever TLS material is configured for *any* purpose.** A client TLS factory is composed (or an adopted one initialized) when *any* of these holds — the broker transport is TLS (`useTls` / `pulsar+ssl://`), a `tlsPolicy(purpose, …)` entry exists for *any* purpose (notably `CLIENT_OAUTH2`), a `tlsFactory(…)` instance was adopted, or the configured OAuth2 plugin carries its own IdP TLS materia [...]
 
+**The admin client follows the same rule, for the same reason.** `PulsarAdmin` selects transport TLS by an `https://` service URL rather than a `useTls` flag, but composition is again not the same question as enablement: an admin whose OAuth2 plugin carries IdP TLS material (folded into `CLIENT_OAUTH2`, see the OAuth2 note) composes a factory even behind a plaintext `http://` admin URL, because otherwise the framework HTTP client that fetches the token has nothing to resolve `CLIENT_OAUT [...]
+
+**One factory per client, on both paths.** `initialize(...)` is called exactly once per `PulsarClient` / `PulsarAdmin`, and `close()` exactly once, however many internal transports share it — a `PulsarAdmin` in particular drives more than one HTTP connector. Custom factories may therefore hold per-instance resources (HSM sessions, rotation tasks) without defending against repeated initialization, and whoever hands a factory *in* — the broker's admin attach is the one in-tree case — trans [...]
+
 The corollary for tools: TLS *modifiers* (hostname verification, allow-insecure, trust/cert paths) shape a policy and never create one, so a secure default shipped in `conf/client.conf` cannot silently make a CLI speak TLS to a plaintext broker. In the CLIs the enabler set is therefore exactly: the `pulsar+ssl://` service-URL scheme, plus (for `pulsar-client`, which reads `client.conf`) an explicit `useTls=true`, which reaches the transport through `tlsPolicy(CLIENT_DEFAULT, …)`. The `ht [...]
 
 **`jsseProvider` — pinning the JSSE (SSLContext) provider.** `TlsPolicy.jsseProvider` names a `java.security.Provider` (matched by `Provider.getName()`) that supplies the JSSE service types the default file-based path builds — the `SSLContext` (`TLS`) itself *and* the `KeyManagerFactory` / `TrustManagerFactory` initialized from the loaded material, the pair that keeps the private-key side inside a FIPS JSSE provider. Naming a crypto-only provider here (BC-FIPS, say) still *resolves*, sin [...]
@@ -1075,7 +1083,12 @@ Modified v4 public API: `org.apache.pulsar.client.api.ClientBuilder` and `org.ap
 
 Dependency note: the v4 `pulsar-client` module gains **three** `api` dependencies — `pulsar-client-api-v5`, `pulsar-tls-factory-api`, and `pulsar-http-client-api` — because the migrated built-in v4 plugins delegate to v5-native bodies (in-scope item #7) and surface the TLS/HTTP SPI types on their exported ABI. The shaded v4 artifacts consequently bundle the v5 auth API, the TLS-factory API, and the HTTP-client API classes — unrelocated, as `org.apache.pulsar.client.api.v5.*`, `org.apache [...]
 
-Removed: `org.apache.pulsar.common.util.PulsarSslFactory`, `PulsarSslConfiguration`, and `org.apache.pulsar.common.util.keystoretls.KeyStoreSSLContext` (intentional breaking change — see Backward & Forward Compatibility); the PIP-337 v4 builder methods `ClientBuilder.sslFactoryPlugin(String)` / `sslFactoryPluginParams(String)` and `PulsarAdminBuilder.sslFactoryPlugin(String)` / `sslFactoryPluginParams(String)` (a v4 source-compatibility break); and the `ClusterData.brokerClientSslFactory [...]
+Removed: `org.apache.pulsar.common.util.PulsarSslFactory`, `PulsarSslConfiguration`, and `org.apache.pulsar.common.util.keystoretls.KeyStoreSSLContext` (intentional breaking change — see Backward & Forward Compatibility); the PIP-337 v4 builder methods `ClientBuilder.sslFactoryPlugin(String)` / `sslFactoryPluginParams(String)` and `PulsarAdminBuilder.sslFactoryPlugin(String)` / `sslFactoryPluginParams(String)` (a v4 source-compatibility break); and — deprecated rather than removed — the  [...]
+
+- **`ClientCnx.newConnectCommand()` → `buildConnectCommand(AuthData)`.** The `protected` hook that built the `CommandConnect` frame also *fetched* the credential (`authentication.getAuthData(...)` then `authenticate(...)`), which cannot work once the credential is a future. Credential resolution moves out to `resolveConnectAuthData()`, and the overridable hook now receives the already-resolved `AuthData`, so an override composes with both the sync and async paths. `ClientCnx` carries no  [...]
+- **`PulsarClientProvider.authenticationTls(String, String)` → `authenticationTls()`.** The mTLS marker plugin no longer carries cert/key paths: material goes through `tlsPolicy(...)`, so `AuthenticationFactory.tls()` is the no-arg form and the provider seam matches it. `PulsarClientProvider` is in the `.internal.` subpackage (framework-driven, "application code neither implements nor consumes"), and both it and the v5 auth API ship first in 5.0, so nothing released depends on the two-ar [...]
+
+Also removed, but **internal-only** (no `@InterfaceStability`, no client-API importer — not a compatibility break): the `org.apache.pulsar.common.util.SecurityUtility` grab-bag (decomposed into the cohesive `PemReader` / `JcaProviders` / `JdkSslContexts` containers), the obsolete `TrustManagerProxy` / `KeyManagerProxy` delegate-swap rotation helpers, and the unused `SSLContextValidatorEngine` — see *Removal* in the Detailed Design.
 
 ### Binary protocol
 
@@ -1108,15 +1121,26 @@ These keys are the successors of PIP-337's `sslFactoryPlugin` / `sslFactoryPlugi
 
 Notes on the table:
 
-- **Defaults.** Both provider keys are unset on every surface, and unset means today's behaviour. The one
-  non-empty legacy default *routed onto the JSSE axis* is the WebSocket proxy's web-listener
-  `tlsProvider=Conscrypt`, which the v4 routing below carries there whenever `jsseProvider` itself is left
-  unset. (`webServiceTlsProvider` on `ServiceConfiguration` and `ProxyConfiguration` also defaults to
-  `Conscrypt`. The broker reads it — `WebService` passes it to Jetty's `SslContextFactory` — but under
-  PIP-337 it never reaches the built `SSLContext`, which `DefaultPulsarSslFactory` supplies with no
-  provider, so the default is inert today; the proxy's web listener reads `tlsProvider` instead. Under
-  this PIP the `WEB` purpose honours it: `webServiceTlsProvider` / `webServiceTlsProtocols` /
-  `webServiceTlsCiphers` take precedence over the binary-listener keys and fall back to them when unset.)
+- **Defaults.** Both provider keys are unset on every surface, and unset means today's behaviour. Under this
+  PIP the `WEB` purpose honours the web-listener keys — `webServiceTlsProvider` / `webServiceTlsProtocols` /
+  `webServiceTlsCiphers` take precedence over the binary-listener keys and fall back to them when unset — and
+  a configured provider name is genuinely pinned, failing loudly when it cannot be resolved. That makes the
+  legacy `Conscrypt` defaults, which were unconditional, untenable as written — so this PIP keeps the
+  default but makes it **conditional on availability**: `webServiceTlsProvider` on `ServiceConfiguration` /
+  `ProxyConfiguration`, the functions worker's `tlsProvider` and the WebSocket proxy's web-listener
+  `tlsProvider` all resolve, when unset, to Conscrypt where Conscrypt actually loads and to the JVM default
+  everywhere else. Under PIP-337 those defaults were inert — the broker passed `webServiceTlsProvider` to
+  Jetty's `SslContextFactory`, which never reaches the built `SSLContext` that `DefaultPulsarSslFactory`
+  supplies with no provider — so no deployment has actually been running on Conscrypt, and honouring the
+  value here is what makes the setting real. An *unconditional* default would then break the web listener
+  out of the box wherever Conscrypt cannot load: `conscrypt-openjdk-uber` ships native libraries for x86_64
+  and, since 2.6.1, aarch64 — but not for every platform Pulsar runs on (s390x, for one), and it need not be
+  on the class path at all. Availability is therefore tested rather than assumed — Conscrypt's own
+  `checkAvailability()`, which is what fails where the native library is missing.
+  The conditionality applies to the **default only**: an operator who names a provider explicitly still gets
+  it pinned, and still gets the loud failure when it cannot be resolved, which is the point of pinning. It
+  applies to **server listeners only**, so it cannot interact with client-side hostname verification — a
+  server does not verify hostnames.
 - **v4 auto-routing, JSSE axis only.** Existing deployments need no config change: a non-engine-literal
   `sslProvider` (client, on the v4 configuration path — `ClientConfigurationData` / `loadConf` / the v4
   client and admin builders; `bin/pulsar-client` reads only `jsseProvider` / `jcaProvider` from
@@ -1174,7 +1198,7 @@ New OpenTelemetry instruments:
 
 ### PIP-337 removal impact
 
-`PulsarSslFactory`, `PulsarSslConfiguration`, `DefaultPulsarSslFactory`, and `KeyStoreSSLContext` are removed (see *Removal* in the Detailed Design), and so are the PIP-337 `sslFactoryPlugin` / `sslFactoryPluginParams` / `brokerClientSslFactoryPlugin` / `brokerClientSslFactoryPluginParams` configuration keys themselves — **nothing PIP-337 is retained** (no `@Deprecated` fields, no config-only vestige). Be explicit about what this means operationally: deployment automation that sets the r [...]
+`PulsarSslFactory`, `PulsarSslConfiguration`, `DefaultPulsarSslFactory`, and `KeyStoreSSLContext` are removed (see *Removal* in the Detailed Design), and so are the PIP-337 `sslFactoryPlugin` / `sslFactoryPluginParams` / `brokerClientSslFactoryPlugin` / `brokerClientSslFactoryPluginParams` **configuration-file keys** themselves — no PIP-337 key survives on a server config, a client config or a builder. The one deliberate exception is `ClusterData`, whose two fields are retained as `@Depr [...]
 
 | Existing surface | Where | Disposition in 5.0 |
 |---|---|---|
@@ -1185,8 +1209,8 @@ New OpenTelemetry instruments:
 | `ClientBuilder.sslFactoryPlugin(String)` / `sslFactoryPluginParams(String)` | **v4 public client API** | **Removed**; successor is the new `tlsFactoryClassName(String)` / `tlsFactoryConfig(String)` on the same builder (name a custom `PulsarTlsFactory` by class + JSON config). Deleting the methods is a v4 **source-compatibility break** — a deliberate 5.0 major-release break; the methods were already deprecated and non-functional, and the removal is inherently loud (a compile error), so  [...]
 | `PulsarAdminBuilder.sslFactoryPlugin(...)` / `sslFactoryPluginParams(...)` | v4 public admin API | Same treatment as `ClientBuilder` — **removed**, with the new `tlsFactoryClassName(String)` / `tlsFactoryConfig(String)` successors |
 | `ClientConfigurationData.sslFactoryPlugin` / `sslFactoryPluginParams` | v4 client config | **Removed**; replaced by new `tlsFactoryClassName` / `tlsFactoryConfig` string fields (today the v4 config carries only a *transient* factory instance set via the v5 builder, no by-name path). A stale, non-default key in a `loadConf` map is rejected by the removed-key validation |
-| `ClusterData.brokerClientSslFactoryPlugin` / `...Params` | admin API + **serialized cluster metadata** | **Removed** (field, builder method, and `ClusterData` API accessor). Broker-client factory-class selection is **broker-level** (`ServiceConfiguration.brokerClientTlsFactoryClassName`), not per-cluster. Per-cluster TLS *material* (the existing `brokerClientTls*` fields on `ClusterData`) keeps working — each outbound replication client owns a factory built from its own configuration,  [...]
-| `pulsar-admin clusters` `--tls-factory-plugin` / `--tls-factory-plugin-params` options | `CmdClusters` CLI | **Removed** — they wrote the now-removed `ClusterData` fields; per-cluster factory selection no longer exists (set the factory broker-level in broker.conf) |
+| `ClusterData.brokerClientSslFactoryPlugin` / `...Params` | admin API + **serialized cluster metadata** | **Deprecated and ignored** (with a WARN), not removed: the field stays in the metadata schema so a cluster written by 4.x still deserializes and reserializes unchanged. Its successors are the new `ClusterData.brokerClientTlsFactoryClassName` / `brokerClientTlsFactoryConfig`, which select the factory for the two outbound legs whose client configuration is built from a cluster entry — [...]
+| `pulsar-admin clusters` `--tls-factory-plugin` / `--tls-factory-plugin-params` options | `CmdClusters` CLI | **Replaced** by `--tls-factory-class-name` / `--tls-factory-config`, which write the successor `ClusterData` fields. The old flags are removed, so a script passing them fails loudly with an unknown-option error rather than silently configuring nothing |
 | `--ssl-factory-plugin`, `--ssl-factory-plugin-params` | `pulsar-testclient` CLI | Removed |
 
 **Component → purpose mapping.** Every current `PulsarSslFactory` consumer and the `TlsPurpose` its TLS resolves to after migration:
@@ -1231,10 +1255,22 @@ New OpenTelemetry instruments:
 
 - **BREAKING CHANGE — TLS hostname verification on by default + SAN required (5.0).** A v4 or v5 client — and any broker / proxy / geo-replication **outbound** connection — that connects to a server whose certificate lacks a SubjectAltName matching the target hostname now **fails the TLS handshake**, where 4.x (hostname verification off by default) silently accepted it. Deprecated CN-field matching is also removed, so a CN-only certificate no longer suffices even when its CN names the ho [...]
 - The v5 client is published as a standalone artifact in 5.0.0-M1 (the existing `pulsar-client-v5` and `pulsar-client-api-v5` modules from PIP-466), **unshaded**. The v5 client will not ship a shaded distribution; applications that need shading to resolve a dependency conflict relocate it themselves in their own build, as they would any other dependency (see the plugin-packaging note above).
-- **BREAKING CHANGE — PIP-337 removed (5.0).** PIP-337 is removed from Pulsar in Pulsar 5.0 since retaining it in the code base causes an additional maintenance burden and most users don't use it. Concretely: the `sslFactoryPlugin` / `sslFactoryPluginParams` / `brokerClientSslFactoryPlugin` / `brokerClientSslFactoryPluginParams` **config-file keys are removed** and a stale non-default value is **rejected at broker/proxy startup** (and at client `loadConf`) by a removed-key validation; th [...]
+- **BREAKING CHANGE — PIP-337 removed (5.0).** PIP-337 is removed from Pulsar in Pulsar 5.0 since retaining it in the code base causes an additional maintenance burden and most users don't use it. Concretely: the `sslFactoryPlugin` / `sslFactoryPluginParams` / `brokerClientSslFactoryPlugin` / `brokerClientSslFactoryPluginParams` **config-file keys are removed** and a stale non-default value is **rejected at broker/proxy startup** (and at client `loadConf`) by a removed-key validation; th [...]
 - **Security-relevant behavior changes (5.0), surfaced here so they aren't buried in the removal inventory:**
   - **`sslProvider` JSSE-provider-name now honored via `jsseProvider`.** A v4 deployment that set `sslProvider` to a JSSE *provider name* (e.g. `Conscrypt`, `SunJSSE`) — which the old code accepted — is now routed to `TlsPolicy.jsseProvider`, so the named provider builds the `SSLContext` on the JDK engine on the binary/client path (**restoring v4 parity**, not dropping the provider); the `OPENSSL` / `OPENSSL_REFCNT` literals still select the native engine. The one residual delta is fail- [...]
-  - **`ClusterData` per-cluster factory field removed — the one silently-dropped case.** The `brokerClientSslFactoryPlugin` / `...Params` fields are **removed** from the `ClusterData` metadata model; broker-client factory-class selection is now broker-level (`brokerClientTlsFactoryClassName`). Cluster metadata written by 4.x still deserializes — the removed field is **lenient-dropped** on read (Jackson ignores the unknown property), so there is no metadata-format break — which means a st [...]
+  - **`ClusterData` per-cluster factory field deprecated — the one silently-ignored case.** The `brokerClientSslFactoryPlugin` / `...Params` fields stay on the `ClusterData` metadata model but are `@Deprecated` and **ignored**, with a WARN naming the cluster so the stale value is visible in the broker log. Their successors are `brokerClientTlsFactoryClassName` / `brokerClientTlsFactoryConfig`, which select the factory for the two outbound legs whose client configuration is built from a c [...]
+  - **The `Conscrypt` web-listener provider defaults become conditional.** `webServiceTlsProvider` (broker
+    and proxy), the functions worker's `tlsProvider` and the WebSocket proxy's web-listener `tlsProvider`
+    shipped a default of `Conscrypt` that was **inert** under PIP-337: it reached only Jetty's
+    `SslContextFactory.setProvider(...)`, which does not affect a factory whose `SSLContext` is supplied
+    pre-built, so no deployment has actually been running on Conscrypt. This PIP honours the value, which
+    makes the setting real — and therefore makes an unconditional default a startup failure wherever
+    Conscrypt has no native library (`conscrypt-openjdk-uber` covers x86_64 and, since 2.6.1, aarch64, but
+    not every platform — s390x, for one). The default is kept and made conditional: unset resolves to
+    Conscrypt where it is available and to the JVM default elsewhere. **Operationally this means web
+    listeners now genuinely run on Conscrypt wherever it loads, where they previously ran on the JVM
+    default**, which is the behaviour the shipped configuration always described. An operator who prefers
+    the JDK provider sets it explicitly.
   - **Jetty web-listener client-cert trust is scoped like Netty's.** With optional client auth (`tlsRequireTrustedClientCertOnConnect=false`), an *untrusted* client certificate is now accepted at the web listener only when `tlsAllowInsecureConnection=true` — `wantClientAuth` no longer implies trust-all, aligning the web listener with the binary listener (see [Security Considerations](#security-considerations)).
 
 ## Upgrade
@@ -1270,7 +1306,7 @@ New OpenTelemetry instruments:
 
 ## Pulsar Geo-Replication Upgrade & Downgrade/Rollback Considerations
 
-Authentication is a per-client concern: each cluster authenticates the replicator client independently using whichever auth plugin is configured, and no auth-related metadata changes. TLS *is* affected, because `ClusterData` carried per-cluster `brokerClientSslFactoryPlugin`/`...Params` fields in 4.x. Those fields are **removed** from the metadata model in 5.0: broker-client factory-class selection becomes **broker-level** (`ServiceConfiguration.brokerClientTlsFactoryClassName`), not per [...]
+Authentication is a per-client concern: each cluster authenticates the replicator client independently using whichever auth plugin is configured, and no auth-related metadata changes. TLS *is* affected, because `ClusterData` carried per-cluster `brokerClientSslFactoryPlugin`/`...Params` fields in 4.x. Those fields are **deprecated and ignored** in 5.0, superseded by `ClusterData.brokerClientTlsFactoryClassName` / `brokerClientTlsFactoryConfig`, which keep factory-class selection per-clus [...]
 
 # Alternatives
 
@@ -1334,7 +1370,7 @@ Design questions considered during this proposal, now resolved. Each records the
 
 # General Notes
 
-**Implementation approach.** The change is naturally ordered so each step keeps the codebase releasable: the new SPI types (the `pulsar-tls-factory-api` and `pulsar-http-client-api` types and the `org.apache.pulsar.client.api.v5.auth` types) land first with no consumers; then the default `FileBasedTlsFactory` / `DefaultBrokerTlsFactory` and the server-side migration (broker, proxy, websocket, functions-worker); then the client-side migration (including the new `tlsFactoryClassName` / `tl [...]
+**Implementation approach.** The change is naturally ordered so each step keeps the codebase releasable: the new SPI types (the `pulsar-tls-factory-api` and `pulsar-http-client-api` types and the `org.apache.pulsar.client.api.v5.auth` types) land first with no consumers; then the default `FileBasedTlsFactory` / `DefaultBrokerTlsFactory` and the server-side migration (broker, proxy, websocket, functions-worker); then the client-side migration (including the new `tlsFactoryClassName` / `tl [...]
 
 **Testing strategy.** Beyond "existing v4 authentication tests pass unmodified" (the acceptance criterion for the shims), the new surface needs: rotation tests (file rotation observed by subscriptions; a failed rotation serves last-good material and recovers on the next change); Jetty `reload(...)` swap tests (new connections use the new context, established connections keep their sessions); per-adapter bridge tests (credential, challenge-response, TLS extraction); fail-fast probing test [...]
 
diff --git a/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/ServiceConfiguration.java b/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/ServiceConfiguration.java
index 25a015577f82..b76cb9e99433 100644
--- a/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/ServiceConfiguration.java
+++ b/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/ServiceConfiguration.java
@@ -52,7 +52,6 @@ import org.apache.pulsar.common.policies.data.TopicType;
 import org.apache.pulsar.common.protocol.Commands;
 import org.apache.pulsar.common.sasl.SaslConstants;
 import org.apache.pulsar.common.topics.TopicsPattern;
-import org.apache.pulsar.common.util.DefaultPulsarSslFactory;
 import org.apache.pulsar.common.util.DirectMemoryUtils;
 import org.apache.pulsar.metadata.api.MetadataStoreFactory;
 import org.apache.pulsar.metadata.impl.ZKMetadataStore;
@@ -209,9 +208,13 @@ public class ServiceConfiguration implements PulsarConfiguration {
             doc = "Specify the TLS provider for the web service: SunJSSE, Conscrypt and etc.\n"
                     + "This names a JSSE (SSLContext) security provider for the Jetty-based web service, which has\n"
                     + "no native TLS engine, so Netty engine values (JDK, OPENSSL, OPENSSL_REFCNT) are not valid\n"
-                    + "provider names here."
+                    + "provider names here. Leave unset (the default) to use Conscrypt when it is available on\n"
+                    + "this platform, else the JVM's default provider; a configured name is pinned and startup\n"
+                    + "fails if it cannot be resolved. Conscrypt ships native libraries for x86_64 and, since\n"
+                    + "2.6.1, aarch64 — but not for every platform, which is why the default falls back instead of\n"
+                    + "failing where it cannot load; pinning it explicitly there does fail."
     )
-    private String webServiceTlsProvider = "Conscrypt";
+    private String webServiceTlsProvider = "";
 
     @FieldContext(
             category = CATEGORY_TLS,
@@ -2049,7 +2052,8 @@ public class ServiceConfiguration implements PulsarConfiguration {
     private boolean tlsEnabled = false;
     @FieldContext(
         category = CATEGORY_TLS,
-        doc = "Tls cert refresh duration in seconds (set 0 to check on every new connection)"
+        doc = "Tls cert refresh duration in seconds. Set 0 to disable the background rotation "
+            + "check, so the TLS material loaded at startup is kept until restart."
     )
     private long tlsCertRefreshCheckDurationSec = 300;
     @FieldContext(
@@ -2075,8 +2079,10 @@ public class ServiceConfiguration implements PulsarConfiguration {
     @FieldContext(
             category = CATEGORY_TLS,
             doc = "Whether the hostname is validated when the broker creates a TLS connection with other brokers"
+                    + " (e.g. geo-replication and broker-to-broker lookup). Enabled by default since Pulsar 5.0"
+                    + " (PIP-478): a peer broker whose certificate does not match its hostname/SAN is rejected."
     )
-    private boolean tlsHostnameVerificationEnabled = false;
+    private boolean tlsHostnameVerificationEnabled = true;
     @FieldContext(
         category = CATEGORY_TLS,
         doc = "Specify the tls protocols the broker will use to negotiate during TLS Handshake.\n\n"
@@ -2096,13 +2102,18 @@ public class ServiceConfiguration implements PulsarConfiguration {
     private boolean tlsRequireTrustedClientCertOnConnect = false;
     @FieldContext(
             category = CATEGORY_TLS,
-            doc = "SSL Factory Plugin class to provide SSLEngine and SSLContext objects. The default "
-                    + " class used is DefaultSslFactory.")
-    private String sslFactoryPlugin = DefaultPulsarSslFactory.class.getName();
+            doc = "PIP-478 TLS factory (PulsarTlsFactory) class name for the broker's server-side TLS "
+                    + "(binary listener and web server; purposes BROKER/PROXY/WEB). An empty value or the "
+                    + "literal 'default' selects the built-in DefaultBrokerTlsFactory composed from these "
+                    + "tls* settings, otherwise the named class is instantiated via its public no-arg "
+                    + "constructor. This is the only server TLS path; the removed PIP-337 sslFactoryPlugin "
+                    + "keys are rejected at startup when set to a non-default value.")
+    private String tlsFactoryClassName = "";
     @FieldContext(
             category = CATEGORY_TLS,
-            doc = "SSL Factory plugin configuration parameters.")
-    private String sslFactoryPluginParams = "";
+            doc = "PIP-478 configuration parameters for tlsFactoryClassName, passed to the factory as its "
+                    + "init params. Accepts a JSON object or a comma-separated key=value list.")
+    private String tlsFactoryConfig = "";
 
     /***** --- Authentication. --- ****/
     @FieldContext(
@@ -4240,18 +4251,28 @@ public class ServiceConfiguration implements PulsarConfiguration {
 
     @FieldContext(
             category = CATEGORY_KEYSTORE_TLS,
-            doc = "Specify the TLS provider for the broker service: \n"
-                    + "When using TLS authentication with CACert, the valid value is either OPENSSL or JDK.\n"
-                    + "When using TLS authentication with KeyStore, available values can be SunJSSE, Conscrypt\n"
-                    + "and etc.\n"
+            doc = "Select the TLS engine for the broker service: either OPENSSL or JDK.\n"
                     + "Leave unset (the default) to let Pulsar choose the engine: the native OpenSSL engine when a\n"
                     + "netty-tcnative binary is available for the platform, otherwise the JDK engine.\n"
-                    + "This key is overloaded across two axes. An engine literal (JDK, OPENSSL, OPENSSL_REFCNT)\n"
-                    + "selects the TLS engine; any other value is read as a JSSE (SSLContext) provider name (e.g.\n"
-                    + "Conscrypt) and selects no native engine."
+                    + "To pin a JSSE (SSLContext) provider such as BCJSSE or Conscrypt, use jsseProvider instead.\n"
+                    + "For compatibility this key stays overloaded across both axes: a value that is not an engine\n"
+                    + "literal (JDK, OPENSSL, OPENSSL_REFCNT) is read as a JSSE provider name and routed to\n"
+                    + "jsseProvider, which takes precedence when both are set."
     )
     private String tlsProvider = null;
 
+    @FieldContext(
+            category = CATEGORY_TLS,
+            doc = "PIP-478: the name of a JSSE (SSLContext) provider — a java.security.Provider that supplies "
+                    + "an SSLContext (TLS) implementation (e.g. the BouncyCastle JSSE provider BCJSSE for FIPS, "
+                    + "with BCFIPS registered separately as the crypto provider it uses) — used to build the "
+                    + "broker's server-side (listener/web) TLS SSLContext. A distinct axis from tlsProvider (the "
+                    + "JDK-vs-OpenSSL engine switch): when set, the default factory builds the JDK engine with "
+                    + "this provider as the SSLContext provider, overriding the engine choice. Resolved via the "
+                    + "ServiceLoader mechanism (with a fallback to an already-registered provider), failing "
+                    + "loudly when unresolvable.")
+    private String jsseProvider = null;
+
     @FieldContext(
             category = CATEGORY_KEYSTORE_TLS,
             doc = "TLS KeyStore type configuration in broker: JKS, PKCS12"
@@ -4396,13 +4417,29 @@ public class ServiceConfiguration implements PulsarConfiguration {
     private Set<String> brokerClientTlsProtocols = new TreeSet<>();
     @FieldContext(
             category = CATEGORY_TLS,
-            doc = "SSL Factory Plugin class used by internal client to provide SSLEngine and SSLContext objects. "
-                    + "The default class used is DefaultSslFactory.")
-    private String brokerClientSslFactoryPlugin = DefaultPulsarSslFactory.class.getName();
+            doc = "PIP-478 TLS factory (PulsarTlsFactory) class name for the broker's own outbound "
+                    + "(broker-to-broker) client connections (purpose BROKER_CLIENT). An empty value or the "
+                    + "literal 'default' selects the built-in default factory composed from the brokerClient "
+                    + "tls* settings, otherwise the named class is instantiated via its public no-arg "
+                    + "constructor. This is the only outbound-client TLS path; the removed PIP-337 "
+                    + "brokerClientSslFactoryPlugin keys are rejected at startup when set to a non-default "
+                    + "value.")
+    private String brokerClientTlsFactoryClassName = "";
+    @FieldContext(
+            category = CATEGORY_TLS,
+            doc = "PIP-478 configuration parameters for brokerClientTlsFactoryClassName. Accepts a JSON "
+                    + "object or a comma-separated key=value list.")
+    private String brokerClientTlsFactoryConfig = "";
     @FieldContext(
             category = CATEGORY_TLS,
-            doc = "SSL Factory plugin configuration parameters used by internal client.")
-    private String brokerClientSslFactoryPluginParams = "";
+            doc = "PIP-478: the name of a JSSE (SSLContext) provider — a java.security.Provider that supplies "
+                    + "an SSLContext (TLS) implementation (e.g. the BouncyCastle JSSE provider BCJSSE for FIPS, "
+                    + "with BCFIPS registered separately as the crypto provider it uses) — used to build the "
+                    + "broker's own outbound (broker-to-broker / replication) client TLS SSLContext. When set, "
+                    + "the default factory builds the JDK engine with this provider as the SSLContext provider, "
+                    + "overriding the engine choice. Resolved via the ServiceLoader mechanism (with a fallback "
+                    + "to an already-registered provider), failing loudly when unresolvable.")
+    private String brokerClientJsseProvider = null;
 
     /* packages management service configurations (begin) */
 
diff --git a/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/tls/DefaultBrokerTlsFactory.java b/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/tls/DefaultBrokerTlsFactory.java
index 57669098632d..2c67ffa0abfc 100644
--- a/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/tls/DefaultBrokerTlsFactory.java
+++ b/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/tls/DefaultBrokerTlsFactory.java
@@ -125,24 +125,28 @@ public class DefaultBrokerTlsFactory extends FileBasedTlsFactory {
      * {@code tlsProtocols}, {@code tlsCiphers} when they are not. Material (PEM or keystore) and the
      * insecure flag are shared with the binary listener, as they are today.
      *
-     * <p>Note the fallback is what makes the precedence safe to apply now: {@code webServiceTlsProvider}
-     * ships a non-blank default of {@code Conscrypt}, so it always wins over {@code tlsProvider} unless an
-     * operator blanks it. That matches the key's documented meaning — it names the JSSE (SSLContext)
-     * provider for the Jetty web listener — and Conscrypt is shipped in the server distribution. On a
-     * platform where the Conscrypt native library cannot load, the provider is not registered and pinning
-     * it fails loudly at startup rather than silently ignoring the configuration.
+     * <p>A configured provider is <em>pinned</em>: if it cannot be resolved, startup fails rather than
+     * silently ignoring the configuration. Left unset, the web listener falls back to Conscrypt when it is
+     * usable on this platform — see {@link TlsFactorySupport#resolveWebJsseProvider} for why that default is
+     * conditional where 4.x's was not.
      */
     @VisibleForTesting
     static TlsPolicy webPolicy(ServiceConfiguration conf) {
-        return serverPolicy(conf,
-                firstNonBlank(conf.getWebServiceTlsProvider(), conf.getTlsProvider()),
+        String provider = firstNonBlank(conf.getWebServiceTlsProvider(), conf.getTlsProvider());
+        return serverPolicy(conf, provider,
+                // Web listeners only: unset means Conscrypt when it is usable on this platform.
+                TlsFactorySupport.resolveWebJsseProvider(null, provider),
                 firstNonEmpty(conf.getWebServiceTlsProtocols(), conf.getTlsProtocols()),
                 firstNonEmpty(conf.getWebServiceTlsCiphers(), conf.getTlsCiphers()));
     }
 
     @VisibleForTesting
     static TlsPolicy serverPolicy(ServiceConfiguration conf) {
-        return serverPolicy(conf, conf.getTlsProvider(), conf.getTlsProtocols(), conf.getTlsCiphers());
+        // The binary listeners keep the JVM default when unset — the Conscrypt default is a web-listener
+        // behaviour, restored from 4.x where only the web keys carried it.
+        return serverPolicy(conf, conf.getTlsProvider(),
+                TlsFactorySupport.resolveJsseProvider(null, conf.getTlsProvider()),
+                conf.getTlsProtocols(), conf.getTlsCiphers());
     }
 
     private static String firstNonBlank(String preferred, String fallback) {
@@ -153,8 +157,8 @@ public class DefaultBrokerTlsFactory extends FileBasedTlsFactory {
         return preferred != null && !preferred.isEmpty() ? preferred : fallback;
     }
 
-    private static TlsPolicy serverPolicy(ServiceConfiguration conf, String provider, Set<String> protocols,
-                                          Set<String> ciphers) {
+    private static TlsPolicy serverPolicy(ServiceConfiguration conf, String provider, String jsseProvider,
+                                          Set<String> protocols, Set<String> ciphers) {
         // enableHostnameVerification is pinned OFF on server-role policies rather than mapped from
         // tlsHostnameVerificationEnabled. That key is the broker's OUTBOUND setting ("whether the hostname
         // is validated when the broker creates a TLS connection with other brokers"), and every existing
@@ -173,9 +177,10 @@ public class DefaultBrokerTlsFactory extends FileBasedTlsFactory {
                 .protocols(toList(protocols))
                 .ciphers(toList(ciphers))
                 // v4 parity: the provider key is overloaded. An engine literal (JDK/OPENSSL/OPENSSL_REFCNT)
-                // selects the Netty engine above and yields null here; any other value is a JSSE provider
-                // name (e.g. Conscrypt), which v4 used to build the SSLContext, so route it to that axis.
-                .jsseProvider(TlsFactorySupport.resolveJsseProvider(null, provider));
+                // selects the Netty engine above and yields null on the JSSE axis; any other value is a JSSE
+                // provider name (e.g. Conscrypt), which v4 used to build the SSLContext. Resolved by the
+                // caller, because the web listener defaults to Conscrypt when unset and the binary ones do not.
+                .jsseProvider(jsseProvider);
         if (conf.isTlsEnabledWithKeyStore()) {
             builder.format(TlsPolicy.Format.KEYSTORE)
                     .keyStoreType(conf.getTlsKeyStoreType())
@@ -221,22 +226,14 @@ public class DefaultBrokerTlsFactory extends FileBasedTlsFactory {
     }
 
     /**
-     * Map {@code tlsCertRefreshCheckDurationSec} onto the factory's poll interval, preserving the v4 meaning of
-     * a non-positive value: every v4 consumer guards its refresh task with {@code > 0}, and
-     * {@link FileBasedTlsFactorySettings} likewise documents {@code <= 0} as "no background poll". Pass it
-     * through rather than substituting the default, so an operator who set {@code 0} still gets no poll.
-     *
-     * <p>Note that {@code 0} therefore disables rotation for the subscribing server purposes, exactly as it
-     * already does on the PIP-337 path. The config key's "set 0 to check on every new connection" wording
-     * describes only the one-shot acquisition paths, which re-stat per request; it has not applied to the
-     * server listeners since they moved to a shared, periodically-refreshed context.
+     * Map {@code tlsCertRefreshCheckDurationSec} onto the factory's poll interval. The rule — including why a
+     * configured {@code 0} must stay "no background poll" rather than become the default — lives on
+     * {@link FileBasedTlsFactorySettings#refreshIntervalSecondsFromConfig(long)}, shared with the proxy,
+     * websocket proxy and functions worker so the four cannot disagree.
      */
     private static int refreshIntervalSeconds(ServiceConfiguration conf) {
-        long configured = conf.getTlsCertRefreshCheckDurationSec();
-        if (configured <= 0) {
-            return 0;
-        }
-        return (int) Math.min(configured, Integer.MAX_VALUE);
+        return FileBasedTlsFactorySettings.refreshIntervalSecondsFromConfig(
+                conf.getTlsCertRefreshCheckDurationSec());
     }
 
     private static List<String> toList(Set<String> values) {
diff --git a/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/tls/TlsFactorySupport.java b/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/tls/TlsFactorySupport.java
index 3c69d79b0273..c63e78f81935 100644
--- a/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/tls/TlsFactorySupport.java
+++ b/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/tls/TlsFactorySupport.java
@@ -22,6 +22,7 @@ import com.fasterxml.jackson.core.type.TypeReference;
 import io.netty.handler.ssl.OpenSsl;
 import io.netty.handler.ssl.SslProvider;
 import io.opentelemetry.api.OpenTelemetry;
+import java.security.Provider;
 import java.time.Clock;
 import java.util.LinkedHashMap;
 import java.util.Locale;
@@ -35,6 +36,7 @@ import java.util.function.Supplier;
 import lombok.CustomLog;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.pulsar.common.util.ObjectMapperFactory;
+import org.apache.pulsar.common.util.tls.JcaProviders;
 import org.apache.pulsar.tls.PulsarTlsFactory;
 import org.apache.pulsar.tls.TlsFactoryInitContext;
 
@@ -271,6 +273,43 @@ public final class TlsFactorySupport {
      * @param engineProviderString the configured {@code tlsProvider}/{@code sslProvider} (may be null/blank)
      * @return the resolved JSSE provider name, or {@code null} for none
      */
+    /**
+     * As {@link #resolveJsseProvider}, but for a <b>web listener</b>, whose JSSE provider defaults to
+     * Conscrypt when nothing is configured and Conscrypt is usable on this platform.
+     *
+     * <p>Conscrypt was the shipped default for the web-listener provider keys before PIP-478 and is
+     * meaningfully faster than the JDK provider, so it stays the default here. What changes is that the
+     * default is now <em>conditional</em>: under PIP-337 the key only reached Jetty's
+     * {@code SslContextFactory.setProvider(...)}, inert on a factory that overrides {@code getSslContext()},
+     * so the shipped default never selected a provider and could not fail. Now that the value is pinned into
+     * the built {@code SSLContext} it can, so it is applied only where Conscrypt actually loads.
+     * {@code conscrypt-openjdk-uber} ships native libraries for x86_64 and — since 2.6.1 — aarch64, but not
+     * for every platform Pulsar runs on (s390x, for one), and it need not be on the class path at all.
+     *
+     * <p>Hence availability rather than mere presence: {@link JcaProviders#CONSCRYPT_PROVIDER} is resolved by
+     * calling Conscrypt's own {@code checkAvailability()}, which fails where the native library is missing, so
+     * this returns {@code null} there and the JVM default applies. A provider the operator names explicitly is
+     * always honoured verbatim and still fails loudly when it cannot be resolved — only the <em>default</em>
+     * is conditional, because a default that breaks a supported platform is not a usable default.
+     *
+     * <p>This applies to server listeners only. Client-side hostname verification is unaffected: a server
+     * does not verify hostnames, so pinning Conscrypt here cannot interact with the SAN-only verification
+     * PIP-478 turns on.
+     *
+     * @param explicitJsseProvider the configured {@code jsseProvider} (may be null/blank)
+     * @param engineProviderString the configured web-listener provider key (may be null/blank)
+     * @return the resolved JSSE provider name, Conscrypt's name when nothing is configured and it is
+     *         available, or {@code null} for the JVM default
+     */
+    public static String resolveWebJsseProvider(String explicitJsseProvider, String engineProviderString) {
+        String configured = resolveJsseProvider(explicitJsseProvider, engineProviderString);
+        if (configured != null) {
+            return configured;
+        }
+        Provider conscrypt = JcaProviders.CONSCRYPT_PROVIDER;
+        return conscrypt == null ? null : conscrypt.getName();
+    }
+
     public static String resolveJsseProvider(String explicitJsseProvider, String engineProviderString) {
         if (StringUtils.isNotBlank(explicitJsseProvider)) {
             return explicitJsseProvider.trim();
diff --git a/pulsar-broker-common/src/main/java/org/apache/pulsar/common/configuration/PulsarConfigurationLoader.java b/pulsar-broker-common/src/main/java/org/apache/pulsar/common/configuration/PulsarConfigurationLoader.java
index 16ea4b5132cd..8a2fba63bd84 100644
--- a/pulsar-broker-common/src/main/java/org/apache/pulsar/common/configuration/PulsarConfigurationLoader.java
+++ b/pulsar-broker-common/src/main/java/org/apache/pulsar/common/configuration/PulsarConfigurationLoader.java
@@ -27,6 +27,7 @@ import java.lang.reflect.Field;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Modifier;
 import java.util.Arrays;
+import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Properties;
@@ -43,6 +44,57 @@ import org.apache.pulsar.broker.ServiceConfiguration;
 @CustomLog
 public class PulsarConfigurationLoader {
 
+    /**
+     * PIP-337 TLS-factory configuration keys removed outright in Pulsar 5.0 (PIP-478). A stale value left in a
+     * config file (broker.conf / proxy.conf) is rejected loudly at load with an actionable migration message —
+     * preserving the fail-loud detection the removed {@code @Deprecated} fields used to provide, without
+     * retaining a functional PIP-337 field.
+     */
+    static final List<String> REMOVED_PIP337_TLS_FACTORY_KEYS = List.of(
+            "sslFactoryPlugin", "sslFactoryPluginParams",
+            "brokerClientSslFactoryPlugin", "brokerClientSslFactoryPluginParams",
+            // broker.conf / proxy.conf pass a brokerClient_<clientKey> property through to the internal client
+            // (PropertiesUtils.filterAndMapProperties strips the "brokerClient_" prefix), where a stale client
+            // sslFactoryPlugin would otherwise be silently dropped by ClientConfigurationData ignoreUnknown.
+            // Reject that prefixed form too so the removed client key cannot bypass the fail-loud detection.
+            "brokerClient_sslFactoryPlugin", "brokerClient_sslFactoryPluginParams");
+
+    /**
+     * The old PIP-337 default {@code sslFactoryPlugin} implementation FQCN. Its class is deleted in Pulsar 5.0
+     * (PIP-478), so this is a plain string literal. A {@code *Plugin} key still carrying this default value is
+     * equivalent to "unset" (no custom factory), so it is tolerated — only a non-default (custom) factory needs
+     * migration.
+     */
+    static final String DEFAULT_PIP337_SSL_FACTORY_CLASS = "org.apache.pulsar.common.util.DefaultPulsarSslFactory";
+
+    /**
+     * Reject a stale, non-default PIP-337 TLS-factory configuration key (removed in Pulsar 5.0, PIP-478) left
+     * in a config file. A key that is absent or blank is tolerated (the default); a {@code *Plugin} key still
+     * naming the old default factory FQCN ({@link #DEFAULT_PIP337_SSL_FACTORY_CLASS}) is also tolerated (it is
+     * equivalent to unset); any other non-blank value fails the load with an actionable migration message
+     * pointing to the {@code tlsFactoryClassName} successor.
+     *
+     * @param properties the loaded configuration properties
+     * @throws IllegalArgumentException if a removed PIP-337 key is present with a non-default value
+     */
+    static void rejectRemovedPip337TlsFactoryKeys(Properties properties) {
+        for (String key : REMOVED_PIP337_TLS_FACTORY_KEYS) {
+            String value = properties.getProperty(key);
+            if (StringUtils.isBlank(value)) {
+                continue;
+            }
+            // A *Plugin key still set to the old default factory FQCN means no custom factory — tolerate it as
+            // if unset. The *PluginParams keys carry no default value, so any non-blank value is rejected.
+            if (key.endsWith("Plugin") && DEFAULT_PIP337_SSL_FACTORY_CLASS.equals(value.trim())) {
+                continue;
+            }
+            throw new IllegalArgumentException("The PIP-337 '" + key + "' configuration key is removed in "
+                    + "Pulsar 5.0 (PIP-478). Migrate the custom SSL factory to a PulsarTlsFactory selected by "
+                    + "tlsFactoryClassName / tlsFactoryConfig (or brokerClientTlsFactoryClassName / "
+                    + "brokerClientTlsFactoryConfig) and remove '" + key + "' from the configuration.");
+        }
+    }
+
     /**
      * Creates PulsarConfiguration and loads it with populated attribute values loaded from provided property file.
      *
@@ -93,6 +145,8 @@ public class PulsarConfigurationLoader {
     public static <T extends PulsarConfiguration> T create(Properties properties,
             Class<? extends PulsarConfiguration> clazz) throws IOException, IllegalArgumentException {
         requireNonNull(properties);
+        // PIP-478: reject a stale, removed PIP-337 sslFactoryPlugin key rather than silently ignoring it.
+        rejectRemovedPip337TlsFactoryKeys(properties);
         T configuration;
         try {
             configuration = (T) clazz.getDeclaredConstructor().newInstance();
diff --git a/pulsar-broker-common/src/main/java/org/apache/pulsar/jetty/tls/JettySslContextFactory.java b/pulsar-broker-common/src/main/java/org/apache/pulsar/jetty/tls/JettySslContextFactory.java
deleted file mode 100644
index 96424c7ee9bd..000000000000
--- a/pulsar-broker-common/src/main/java/org/apache/pulsar/jetty/tls/JettySslContextFactory.java
+++ /dev/null
@@ -1,82 +0,0 @@
-/*
- * 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.pulsar.jetty.tls;
-
-import java.util.Set;
-import javax.net.ssl.SSLContext;
-import lombok.CustomLog;
-import org.apache.pulsar.common.util.PulsarSslFactory;
-import org.apache.pulsar.common.util.SecurityUtility;
-import org.eclipse.jetty.util.ssl.SslContextFactory;
-
-@CustomLog
-public class JettySslContextFactory {
-    static {
-        // DO NOT EDIT - Load Conscrypt provider
-        if (SecurityUtility.CONSCRYPT_PROVIDER != null) {
-        }
-    }
-
-    public static SslContextFactory.Server createSslContextFactory(String sslProviderString,
-                                                                   PulsarSslFactory pulsarSslFactory,
-                                                                   boolean requireTrustedClientCertOnConnect,
-                                                                   Set<String> ciphers, Set<String> protocols) {
-        return new JettySslContextFactory.Server(sslProviderString, pulsarSslFactory,
-                requireTrustedClientCertOnConnect, ciphers, protocols);
-    }
-
-    private static class Server extends SslContextFactory.Server {
-        private final PulsarSslFactory pulsarSslFactory;
-
-        public Server(String sslProviderString, PulsarSslFactory pulsarSslFactory,
-                      boolean requireTrustedClientCertOnConnect, Set<String> ciphers, Set<String> protocols) {
-            super();
-            this.pulsarSslFactory = pulsarSslFactory;
-
-            if (ciphers != null && ciphers.size() > 0) {
-                this.setIncludeCipherSuites(ciphers.toArray(new String[0]));
-            }
-
-            if (protocols != null && protocols.size() > 0) {
-                this.setIncludeProtocols(protocols.toArray(new String[0]));
-            }
-
-            if (sslProviderString != null && !sslProviderString.equals("")) {
-                setProvider(sslProviderString);
-            }
-
-            if (requireTrustedClientCertOnConnect) {
-                this.setNeedClientAuth(true);
-                this.setTrustAll(false);
-            } else {
-                this.setWantClientAuth(true);
-                this.setTrustAll(true);
-            }
-
-            // https://jetty.org/docs/jetty/12.1/operations-guide/protocols/index.html#ssl-sni
-            // Set to false for backwards compatibility with Jetty 9.x
-            setSniRequired(false);
-        }
-
-        @Override
-        public SSLContext getSslContext() {
-            return this.pulsarSslFactory.getInternalSslContext();
-        }
-    }
-}
diff --git a/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/tls/DefaultBrokerTlsPolicyTest.java b/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/tls/DefaultBrokerTlsPolicyTest.java
index 4628c0d128d8..a134e4496a91 100644
--- a/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/tls/DefaultBrokerTlsPolicyTest.java
+++ b/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/tls/DefaultBrokerTlsPolicyTest.java
@@ -19,8 +19,10 @@
 package org.apache.pulsar.broker.tls;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import java.security.Security;
 import java.util.Set;
 import org.apache.pulsar.broker.ServiceConfiguration;
+import org.apache.pulsar.common.util.tls.JcaProviders;
 import org.apache.pulsar.tls.TlsPolicy;
 import org.testng.annotations.Test;
 
@@ -108,6 +110,46 @@ public class DefaultBrokerTlsPolicyTest {
         assertThat(DefaultBrokerTlsFactory.webPolicy(enabled).enableHostnameVerification()).isFalse();
     }
 
+    @Test
+    public void theDefaultConfigurationPinsNoJsseProviderOnTheNonWebPurposes() {
+        // A configured JSSE provider is pinned and fails startup when it cannot be resolved, so the binary
+        // and broker-client purposes must not pin one out of the box — only the web listener carries a
+        // default, and only conditionally (see below).
+        ServiceConfiguration conf = conf();
+
+        assertThat(DefaultBrokerTlsFactory.serverPolicy(conf).jsseProvider()).isNull();
+        assertThat(DefaultBrokerTlsFactory.brokerClientPolicy(conf).jsseProvider()).isNull();
+    }
+
+    @Test
+    public void theWebListenerDefaultsToConscryptOnlyWhenItIsAvailable() {
+        // Conscrypt was the shipped default for webServiceTlsProvider before PIP-478 and is kept, but the
+        // default is now conditional on Conscrypt actually loading: the value is pinned into the built
+        // SSLContext rather than reaching Jetty's inert setProvider(...), so an unconditional default would
+        // fail startup wherever the native library is missing (the uber jar covers x86_64 and, since 2.6.1,
+        // aarch64 — not every platform). Both branches are asserted so this holds either way.
+        ServiceConfiguration conf = conf();
+
+        String webProvider = DefaultBrokerTlsFactory.webPolicy(conf).jsseProvider();
+        if (JcaProviders.CONSCRYPT_PROVIDER == null) {
+            assertThat(webProvider).as("no Conscrypt on this platform => the JVM default applies").isNull();
+        } else {
+            assertThat(webProvider).as("Conscrypt is available => it is the web-listener default")
+                    .isEqualTo(JcaProviders.CONSCRYPT_PROVIDER.getName());
+            // Whatever the default resolves to must be resolvable, or startup would fail on it.
+            assertThat(Security.getProvider(webProvider)).as("the default must be a registered provider")
+                    .isNotNull();
+        }
+    }
+
+    @Test
+    public void anExplicitWebProviderStillWinsOverTheConscryptDefault() {
+        ServiceConfiguration conf = conf();
+        conf.setWebServiceTlsProvider("SunJSSE");
+
+        assertThat(DefaultBrokerTlsFactory.webPolicy(conf).jsseProvider()).isEqualTo("SunJSSE");
+    }
+
     @Test
     public void anEngineLiteralInTheWebProviderSelectsNoJsseProvider() {
         // The provider key is overloaded across two axes; an engine literal must not land on the JSSE one.
diff --git a/pulsar-broker-common/src/test/java/org/apache/pulsar/common/configuration/PulsarConfigurationLoaderTest.java b/pulsar-broker-common/src/test/java/org/apache/pulsar/common/configuration/PulsarConfigurationLoaderTest.java
index 6bf1fc1236bf..f124c36711d7 100644
--- a/pulsar-broker-common/src/test/java/org/apache/pulsar/common/configuration/PulsarConfigurationLoaderTest.java
+++ b/pulsar-broker-common/src/test/java/org/apache/pulsar/common/configuration/PulsarConfigurationLoaderTest.java
@@ -25,6 +25,7 @@ import static org.testng.Assert.assertFalse;
 import static org.testng.Assert.assertNotNull;
 import static org.testng.Assert.assertThrows;
 import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.expectThrows;
 import com.google.common.collect.Sets;
 import java.io.File;
 import java.io.FileInputStream;
@@ -86,6 +87,69 @@ public class PulsarConfigurationLoaderTest {
                 () -> PulsarConfigurationLoader.convertFrom(new MockConfiguration(), false));
     }
 
+    // PIP-478: a stale, removed PIP-337 sslFactoryPlugin key set to a non-default value is rejected loudly at
+    // config-file load with an actionable migration message, replacing the removed @Deprecated fail-loud field.
+    @Test
+    public void testRemovedPip337SslFactoryPluginKeysRejectedLoudly() {
+        for (String key : new String[] {"sslFactoryPlugin", "sslFactoryPluginParams",
+                "brokerClientSslFactoryPlugin", "brokerClientSslFactoryPluginParams",
+                // PIP-478 (FIX): broker.conf passes brokerClient_<clientKey> through to the internal client, so
+                // the prefixed form of the client removed keys must be rejected too (it would otherwise be
+                // silently dropped by ClientConfigurationData ignoreUnknown).
+                "brokerClient_sslFactoryPlugin", "brokerClient_sslFactoryPluginParams"}) {
+            Properties props = new Properties();
+            props.setProperty("clusterName", "test");
+            props.setProperty(key, "com.example.CustomSslFactory");
+            IllegalArgumentException ex = expectThrows(IllegalArgumentException.class,
+                    () -> PulsarConfigurationLoader.create(props, ServiceConfiguration.class));
+            assertTrue(ex.getMessage().contains(key), "message should name the removed key: " + ex.getMessage());
+            assertTrue(ex.getMessage().contains("tlsFactoryClassName"),
+                    "message should point to the successor: " + ex.getMessage());
+        }
+        // A blank value (the default) is tolerated -> loads cleanly.
+        Properties blank = new Properties();
+        blank.setProperty("clusterName", "test");
+        blank.setProperty("sslFactoryPlugin", "");
+        blank.setProperty("brokerClientSslFactoryPlugin", "");
+        assertNotNull(expectNoThrow(() -> PulsarConfigurationLoader.create(blank, ServiceConfiguration.class)));
+    }
+
+    // PIP-478 (FIX): a *Plugin key still naming the OLD DEFAULT factory FQCN is equivalent to "unset" (no
+    // custom factory), so it is tolerated — only a non-default (custom) value needs migration. The *PluginParams
+    // keys carry no default value, so any non-blank value is still rejected.
+    @Test
+    public void testRemovedPip337DefaultSslFactoryFqcnTolerated() {
+        String defaultFqcn = "org.apache.pulsar.common.util.DefaultPulsarSslFactory";
+        for (String key : new String[] {"sslFactoryPlugin", "brokerClientSslFactoryPlugin",
+                "brokerClient_sslFactoryPlugin"}) {
+            Properties props = new Properties();
+            props.setProperty("clusterName", "test");
+            props.setProperty(key, defaultFqcn);
+            assertNotNull(expectNoThrow(() -> PulsarConfigurationLoader.create(props, ServiceConfiguration.class)),
+                    "old default factory FQCN on '" + key + "' should be tolerated");
+        }
+        // A custom (non-default) value on the same key is still rejected.
+        Properties custom = new Properties();
+        custom.setProperty("clusterName", "test");
+        custom.setProperty("sslFactoryPlugin", "com.acme.CustomFactory");
+        expectThrows(IllegalArgumentException.class,
+                () -> PulsarConfigurationLoader.create(custom, ServiceConfiguration.class));
+        // The default FQCN is NOT a *PluginParams default -> a non-blank params value is still rejected.
+        Properties params = new Properties();
+        params.setProperty("clusterName", "test");
+        params.setProperty("sslFactoryPluginParams", defaultFqcn);
+        expectThrows(IllegalArgumentException.class,
+                () -> PulsarConfigurationLoader.create(params, ServiceConfiguration.class));
+    }
+
+    private static <T> T expectNoThrow(java.util.concurrent.Callable<T> callable) {
+        try {
+            return callable.call();
+        } catch (Exception e) {
+            throw new AssertionError("expected no exception but got: " + e, e);
+        }
+    }
+
     // Deprecation warning suppressed as this test targets deprecated methods
     @SuppressWarnings("deprecation")
     @Test
diff --git a/pulsar-broker-common/src/test/java/org/apache/pulsar/jetty/tls/JettySslContextFactoryTest.java b/pulsar-broker-common/src/test/java/org/apache/pulsar/jetty/tls/JettySslContextFactoryTest.java
deleted file mode 100644
index 0d58153b0d78..000000000000
--- a/pulsar-broker-common/src/test/java/org/apache/pulsar/jetty/tls/JettySslContextFactoryTest.java
+++ /dev/null
@@ -1,206 +0,0 @@
-/*
- * 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.pulsar.jetty.tls;
-
-import com.google.common.io.Resources;
-import java.io.IOException;
-import java.security.GeneralSecurityException;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import javax.net.ssl.SSLContext;
-import javax.net.ssl.SSLHandshakeException;
-import lombok.Cleanup;
-import lombok.CustomLog;
-import org.apache.http.client.methods.HttpGet;
-import org.apache.http.config.RegistryBuilder;
-import org.apache.http.conn.socket.ConnectionSocketFactory;
-import org.apache.http.conn.ssl.NoopHostnameVerifier;
-import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
-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.impl.conn.PoolingHttpClientConnectionManager;
-import org.apache.pulsar.common.util.DefaultPulsarSslFactory;
-import org.apache.pulsar.common.util.PulsarSslConfiguration;
-import org.apache.pulsar.common.util.PulsarSslFactory;
-import org.apache.pulsar.common.util.SecurityUtility;
-import org.eclipse.jetty.server.Server;
-import org.eclipse.jetty.server.ServerConnector;
-import org.eclipse.jetty.util.ssl.SslContextFactory;
-import org.testng.annotations.Test;
-
-@CustomLog
-public class JettySslContextFactoryTest {
-
-    @Test
-    public void testJettyTlsServerTls() throws Exception {
-        @Cleanup("stop")
-        Server server = new Server();
-        List<ServerConnector> connectors = new ArrayList<>();
-        PulsarSslConfiguration sslConfiguration = PulsarSslConfiguration.builder()
-                .tlsTrustCertsFilePath(Resources.getResource("ssl/my-ca/ca.pem").getPath())
-                .tlsCertificateFilePath(Resources.getResource("ssl/my-ca/server-ca.pem").getPath())
-                .tlsKeyFilePath(Resources.getResource("ssl/my-ca/server-key.pem").getPath())
-                .allowInsecureConnection(false)
-                .requireTrustedClientCertOnConnect(true)
-                .tlsEnabledWithKeystore(false)
-                .isHttps(true)
-                .build();
-        PulsarSslFactory sslFactory = new DefaultPulsarSslFactory();
-        sslFactory.initialize(sslConfiguration);
-        sslFactory.createInternalSslContext();
-        SslContextFactory.Server factory = JettySslContextFactory.createSslContextFactory(null,
-                sslFactory, true, null, null);
-
-        ServerConnector connector = new ServerConnector(server, factory);
-        connector.setPort(0);
-        connectors.add(connector);
-        server.setConnectors(connectors.toArray(new ServerConnector[0]));
-        server.start();
-        // client connect
-        HttpClientBuilder httpClientBuilder = HttpClients.custom();
-        RegistryBuilder<ConnectionSocketFactory> registryBuilder = RegistryBuilder.create();
-        registryBuilder.register("https",
-                new SSLConnectionSocketFactory(getClientSslContext(), new NoopHostnameVerifier()));
-        PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(registryBuilder.build());
-        httpClientBuilder.setConnectionManager(cm);
-        @Cleanup
-        CloseableHttpClient httpClient = httpClientBuilder.build();
-        HttpGet httpGet = new HttpGet("https://localhost:" + connector.getLocalPort());
-        httpClient.execute(httpGet);
-    }
-
-    @Test(expectedExceptions = SSLHandshakeException.class)
-    public void testJettyTlsServerInvalidTlsProtocol() throws Exception {
-        @Cleanup("stop")
-        Server server = new Server();
-        List<ServerConnector> connectors = new ArrayList<>();
-        PulsarSslConfiguration sslConfiguration = PulsarSslConfiguration.builder()
-                .tlsProtocols(new HashSet<String>() {
-                    {
-                        this.add("TLSv1.3");
-                    }
-                })
-                .tlsTrustCertsFilePath(Resources.getResource("ssl/my-ca/ca.pem").getPath())
-                .tlsCertificateFilePath(Resources.getResource("ssl/my-ca/server-ca.pem").getPath())
-                .tlsKeyFilePath(Resources.getResource("ssl/my-ca/server-key.pem").getPath())
-                .allowInsecureConnection(false)
-                .requireTrustedClientCertOnConnect(true)
-                .tlsEnabledWithKeystore(false)
-                .isHttps(true)
-                .build();
-        PulsarSslFactory sslFactory = new DefaultPulsarSslFactory();
-        sslFactory.initialize(sslConfiguration);
-        sslFactory.createInternalSslContext();
-        SslContextFactory.Server factory = JettySslContextFactory.createSslContextFactory(null,
-                sslFactory, true, null,
-                new HashSet<String>() {
-                    {
-                        this.add("TLSv1.3");
-                    }
-                });
-        factory.setHostnameVerifier((s, sslSession) -> true);
-        ServerConnector connector = new ServerConnector(server, factory);
-        connector.setPort(0);
-        connectors.add(connector);
-        server.setConnectors(connectors.toArray(new ServerConnector[0]));
-        server.start();
-        // client connect
-        HttpClientBuilder httpClientBuilder = HttpClients.custom();
-        RegistryBuilder<ConnectionSocketFactory> registryBuilder = RegistryBuilder.create();
-        registryBuilder.register("https", new SSLConnectionSocketFactory(getClientSslContext(),
-                new String[]{"TLSv1.2"}, null, new NoopHostnameVerifier()));
-        PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(registryBuilder.build());
-        httpClientBuilder.setConnectionManager(cm);
-        @Cleanup
-        CloseableHttpClient httpClient = httpClientBuilder.build();
-        HttpGet httpGet = new HttpGet("https://localhost:" + connector.getLocalPort());
-        httpClient.execute(httpGet);
-    }
-
-    @Test(expectedExceptions = SSLHandshakeException.class)
-    public void testJettyTlsServerInvalidCipher() throws Exception {
-        @Cleanup("stop")
-        Server server = new Server();
-        List<ServerConnector> connectors = new ArrayList<>();
-        PulsarSslConfiguration sslConfiguration = PulsarSslConfiguration.builder()
-                .tlsCiphers(new HashSet<String>() {
-                    {
-                        this.add("TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256");
-                    }
-                })
-                .tlsProtocols(new HashSet<String>() {
-                    {
-                        this.add("TLSv1.3");
-                    }
-                })
-                .tlsTrustCertsFilePath(Resources.getResource("ssl/my-ca/ca.pem").getPath())
-                .tlsCertificateFilePath(Resources.getResource("ssl/my-ca/server-ca.pem").getPath())
-                .tlsKeyFilePath(Resources.getResource("ssl/my-ca/server-key.pem").getPath())
-                .allowInsecureConnection(false)
-                .requireTrustedClientCertOnConnect(true)
-                .isHttps(true)
-                .tlsEnabledWithKeystore(false)
-                .build();
-        PulsarSslFactory sslFactory = new DefaultPulsarSslFactory();
-        sslFactory.initialize(sslConfiguration);
-        sslFactory.createInternalSslContext();
-        SslContextFactory.Server factory = JettySslContextFactory.createSslContextFactory(null,
-                sslFactory, true,
-                new HashSet<String>() {
-                    {
-                        this.add("TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256");
-                    }
-                },
-                new HashSet<String>() {
-                    {
-                        this.add("TLSv1.3");
-                    }
-                });
-        factory.setHostnameVerifier((s, sslSession) -> true);
-        ServerConnector connector = new ServerConnector(server, factory);
-        connector.setPort(0);
-        connectors.add(connector);
-        server.setConnectors(connectors.toArray(new ServerConnector[0]));
-        server.start();
-        // client connect
-        HttpClientBuilder httpClientBuilder = HttpClients.custom();
-        RegistryBuilder<ConnectionSocketFactory> registryBuilder = RegistryBuilder.create();
-        registryBuilder.register("https", new SSLConnectionSocketFactory(getClientSslContext(),
-                new String[]{"TLSv1.2"}, new String[]{"TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384"},
-                new NoopHostnameVerifier()));
-        PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(registryBuilder.build());
-        httpClientBuilder.setConnectionManager(cm);
-        @Cleanup
-        CloseableHttpClient httpClient = httpClientBuilder.build();
-        HttpGet httpGet = new HttpGet("https://localhost:" + connector.getLocalPort());
-        httpClient.execute(httpGet);
-    }
-
-    private static SSLContext getClientSslContext() throws GeneralSecurityException, IOException {
-        return SecurityUtility.createSslContext(
-                false,
-                Resources.getResource("ssl/my-ca/ca.pem").getPath(),
-                Resources.getResource("ssl/my-ca/client-ca.pem").getPath(),
-                Resources.getResource("ssl/my-ca/client-key.pem").getPath(),
-                null
-        );
-    }
-}
diff --git a/pulsar-broker-common/src/test/java/org/apache/pulsar/jetty/tls/JettySslContextFactoryWithKeyStoreTest.java b/pulsar-broker-common/src/test/java/org/apache/pulsar/jetty/tls/JettySslContextFactoryWithKeyStoreTest.java
deleted file mode 100644
index b66bb8dc6bd6..000000000000
--- a/pulsar-broker-common/src/test/java/org/apache/pulsar/jetty/tls/JettySslContextFactoryWithKeyStoreTest.java
+++ /dev/null
@@ -1,252 +0,0 @@
-/*
- * 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.pulsar.jetty.tls;
-
-import com.google.common.io.Resources;
-import java.io.FileInputStream;
-import java.security.KeyStore;
-import java.security.SecureRandom;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import javax.net.ssl.KeyManager;
-import javax.net.ssl.KeyManagerFactory;
-import javax.net.ssl.SSLContext;
-import javax.net.ssl.SSLHandshakeException;
-import javax.net.ssl.TrustManagerFactory;
-import lombok.Cleanup;
-import lombok.CustomLog;
-import org.apache.http.client.methods.HttpGet;
-import org.apache.http.config.RegistryBuilder;
-import org.apache.http.conn.socket.ConnectionSocketFactory;
-import org.apache.http.conn.ssl.NoopHostnameVerifier;
-import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
-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.impl.conn.PoolingHttpClientConnectionManager;
-import org.apache.logging.log4j.Level;
-import org.apache.logging.log4j.core.config.Configurator;
-import org.apache.pulsar.common.util.DefaultPulsarSslFactory;
-import org.apache.pulsar.common.util.PulsarSslConfiguration;
-import org.apache.pulsar.common.util.PulsarSslFactory;
-import org.eclipse.jetty.server.Server;
-import org.eclipse.jetty.server.ServerConnector;
-import org.eclipse.jetty.util.ssl.SslContextFactory;
-import org.testng.annotations.Test;
-
-@CustomLog
-public class JettySslContextFactoryWithKeyStoreTest {
-    static final String BROKEN_KEY_STORE_PATH =
-            Resources.getResource("certificate-authority/jks/broker.keystore.jks").getPath();
-    static final String BROKER_TRUST_STORE_PATH =
-            Resources.getResource("certificate-authority/jks/broker.truststore.jks").getPath();
-    static final String CLIENT_KEY_STORE_PATH =
-            Resources.getResource("certificate-authority/jks/client.keystore.jks").getPath();
-    static final String CLIENT_TRUST_STORE_PATH =
-            Resources.getResource("certificate-authority/jks/client.truststore.jks").getPath();
-    static final String KEY_STORE_TYPE = "JKS";
-    static final String KEY_STORE_PASSWORD = "111111";
-
-    @Test
-    public void testJettyTlsServerTls() throws Exception {
-        @Cleanup("stop")
-        Server server = new Server();
-        List<ServerConnector> connectors = new ArrayList<>();
-        PulsarSslConfiguration sslConfiguration = PulsarSslConfiguration.builder()
-                .tlsKeyStoreType(KEY_STORE_TYPE)
-                .tlsKeyStorePath(BROKEN_KEY_STORE_PATH)
-                .tlsKeyStorePassword(KEY_STORE_PASSWORD)
-                .tlsTrustStoreType(KEY_STORE_TYPE)
-                .tlsTrustStorePath(CLIENT_TRUST_STORE_PATH)
-                .tlsTrustStorePassword(KEY_STORE_PASSWORD)
-                .requireTrustedClientCertOnConnect(true)
-                .tlsEnabledWithKeystore(true)
-                .isHttps(true)
-                .build();
-        PulsarSslFactory sslFactory = new DefaultPulsarSslFactory();
-        sslFactory.initialize(sslConfiguration);
-        sslFactory.createInternalSslContext();
-        SslContextFactory.Server factory = JettySslContextFactory.createSslContextFactory(null,
-                sslFactory, true, null, null);
-        factory.setHostnameVerifier((s, sslSession) -> true);
-        ServerConnector connector = new ServerConnector(server, factory);
-        connector.setPort(0);
-        connectors.add(connector);
-        server.setConnectors(connectors.toArray(new ServerConnector[0]));
-        server.start();
-        // client connect
-        HttpClientBuilder httpClientBuilder = HttpClients.custom();
-        RegistryBuilder<ConnectionSocketFactory> registryBuilder = RegistryBuilder.create();
-        registryBuilder.register("https",
-                new SSLConnectionSocketFactory(getClientSslContext(), new NoopHostnameVerifier()));
-        PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(registryBuilder.build());
-        httpClientBuilder.setConnectionManager(cm);
-        @Cleanup
-        CloseableHttpClient httpClient = httpClientBuilder.build();
-        HttpGet httpGet = new HttpGet("https://localhost:" + connector.getLocalPort());
-        httpClient.execute(httpGet);
-    }
-
-    @Test(expectedExceptions = SSLHandshakeException.class)
-    public void testJettyTlsServerInvalidTlsProtocol() throws Exception {
-        Configurator.setRootLevel(Level.INFO);
-        @Cleanup("stop")
-        Server server = new Server();
-        List<ServerConnector> connectors = new ArrayList<>();
-        PulsarSslConfiguration sslConfiguration = PulsarSslConfiguration.builder()
-                .tlsKeyStoreType(KEY_STORE_TYPE)
-                .tlsKeyStorePath(BROKEN_KEY_STORE_PATH)
-                .tlsKeyStorePassword(KEY_STORE_PASSWORD)
-                .tlsTrustStoreType(KEY_STORE_TYPE)
-                .tlsTrustStorePath(CLIENT_TRUST_STORE_PATH)
-                .tlsTrustStorePassword(KEY_STORE_PASSWORD)
-                .tlsProtocols(new HashSet<String>() {
-                    {
-                        this.add("TLSv1.3");
-                    }
-                })
-                .requireTrustedClientCertOnConnect(true)
-                .tlsEnabledWithKeystore(true)
-                .isHttps(true)
-                .build();
-        PulsarSslFactory sslFactory = new DefaultPulsarSslFactory();
-        sslFactory.initialize(sslConfiguration);
-        sslFactory.createInternalSslContext();
-        SslContextFactory.Server factory = JettySslContextFactory.createSslContextFactory(null,
-                sslFactory, true, null,
-                new HashSet<String>() {
-                    {
-                        this.add("TLSv1.3");
-                    }
-                });
-        factory.setHostnameVerifier((s, sslSession) -> true);
-        ServerConnector connector = new ServerConnector(server, factory);
-        connector.setPort(0);
-        connectors.add(connector);
-        server.setConnectors(connectors.toArray(new ServerConnector[0]));
-        server.start();
-        // client connect
-        HttpClientBuilder httpClientBuilder = HttpClients.custom();
-        RegistryBuilder<ConnectionSocketFactory> registryBuilder = RegistryBuilder.create();
-        registryBuilder.register("https", new SSLConnectionSocketFactory(getClientSslContext(),
-                new String[]{"TLSv1.2"}, null, new NoopHostnameVerifier()));
-        PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(registryBuilder.build());
-        httpClientBuilder.setConnectionManager(cm);
-        @Cleanup
-        CloseableHttpClient httpClient = httpClientBuilder.build();
-        HttpGet httpGet = new HttpGet("https://localhost:" + connector.getLocalPort());
-        httpClient.execute(httpGet);
-    }
-
-    @Test(expectedExceptions = SSLHandshakeException.class)
-    public void testJettyTlsServerInvalidCipher() throws Exception {
-        @Cleanup("stop")
-        Server server = new Server();
-        List<ServerConnector> connectors = new ArrayList<>();
-        PulsarSslConfiguration sslConfiguration = PulsarSslConfiguration.builder()
-                .tlsKeyStoreType(KEY_STORE_TYPE)
-                .tlsKeyStorePath(BROKEN_KEY_STORE_PATH)
-                .tlsKeyStorePassword(KEY_STORE_PASSWORD)
-                .tlsTrustStoreType(KEY_STORE_TYPE)
-                .tlsTrustStorePath(CLIENT_TRUST_STORE_PATH)
-                .tlsTrustStorePassword(KEY_STORE_PASSWORD)
-                .tlsCiphers(new HashSet<String>() {
-                    {
-                        this.add("TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256");
-                    }
-                })
-                .tlsProtocols(new HashSet<String>() {
-                    {
-                        this.add("TLSv1.3");
-                    }
-                })
-                .requireTrustedClientCertOnConnect(true)
-                .tlsEnabledWithKeystore(true)
-                .isHttps(true)
-                .build();
-        PulsarSslFactory sslFactory = new DefaultPulsarSslFactory();
-        sslFactory.initialize(sslConfiguration);
-        sslFactory.createInternalSslContext();
-        SslContextFactory.Server factory = JettySslContextFactory.createSslContextFactory(null,
-                sslFactory, true,
-                new HashSet<String>() {
-                    {
-                        this.add("TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256");
-                    }
-                },
-                new HashSet<String>() {
-                    {
-                        this.add("TLSv1.2");
-                        this.add("TLSv1.3");
-                    }
-                });
-        factory.setHostnameVerifier((s, sslSession) -> true);
-        ServerConnector connector = new ServerConnector(server, factory);
-        connector.setPort(0);
-        connectors.add(connector);
-        server.setConnectors(connectors.toArray(new ServerConnector[0]));
-        server.start();
-        // client connect
-        HttpClientBuilder httpClientBuilder = HttpClients.custom();
-        RegistryBuilder<ConnectionSocketFactory> registryBuilder = RegistryBuilder.create();
-        registryBuilder.register("https", new SSLConnectionSocketFactory(getClientSslContext(),
-                new String[]{"TLSv1.2"}, new String[]{"TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384"},
-                new NoopHostnameVerifier()));
-        PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(registryBuilder.build());
-        httpClientBuilder.setConnectionManager(cm);
-        @Cleanup
-        CloseableHttpClient httpClient = httpClientBuilder.build();
-        HttpGet httpGet = new HttpGet("https://localhost:" + connector.getLocalPort());
-        httpClient.execute(httpGet);
-    }
-
-    private static SSLContext getClientSslContext() {
-        return getSslContext(CLIENT_KEY_STORE_PATH, KEY_STORE_PASSWORD, BROKER_TRUST_STORE_PATH, KEY_STORE_PASSWORD);
-    }
-
-    private static SSLContext getSslContext(String keyStorePath, String keyStorePassword,
-                                            String trustStorePath, String trustStorePassword) {
-        try {
-            SSLContext sslContext = SSLContext.getInstance("TLS");
-            // key store
-            KeyManagerFactory keyManagerFactory =
-                    KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
-            KeyStore keyStore = KeyStore.getInstance("JKS");
-            try (FileInputStream inputStream = new FileInputStream(keyStorePath)) {
-                keyStore.load(inputStream, keyStorePassword.toCharArray());
-            }
-            keyManagerFactory.init(keyStore, keyStorePassword.toCharArray());
-            KeyManager[] keyManagers = keyManagerFactory.getKeyManagers();
-            // trust store
-            TrustManagerFactory trustManagerFactory =
-                    TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
-            KeyStore trustStore = KeyStore.getInstance("JKS");
-            try (FileInputStream inputStream = new FileInputStream(trustStorePath)) {
-                trustStore.load(inputStream, trustStorePassword.toCharArray());
-            }
-            trustManagerFactory.init(trustStore);
-            sslContext.init(keyManagers, trustManagerFactory.getTrustManagers(), new SecureRandom());
-            return sslContext;
-        } catch (Exception e) {
-            log.error().exception(e).log("load ssl context error");
-            return null;
-        }
-    }
-}
diff --git a/pulsar-broker/build.gradle.kts b/pulsar-broker/build.gradle.kts
index afdd94c92556..e4c1cfb2b04a 100644
--- a/pulsar-broker/build.gradle.kts
+++ b/pulsar-broker/build.gradle.kts
@@ -31,6 +31,9 @@ dependencies {
     api(project(":pulsar-broker-common"))
     api(project(":pulsar-client-original"))
     implementation(project(":pulsar-client-admin-original"))
+    // PIP-478: PulsarChannelInitializer / WebService use the TLS factory SPI directly (private tlsFactory
+    // fields); it is not exposed on the broker's ABI, so `implementation`.
+    implementation(project(":pulsar-tls-factory-api"))
     api(project(":pulsar-websocket"))
     implementation(project(":pulsar-cli-utils"))
     implementation(project(":pulsar-transaction:pulsar-transaction-common"))
diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/PulsarService.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/PulsarService.java
index f80465ed3c65..526f5cce5570 100644
--- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/PulsarService.java
+++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/PulsarService.java
@@ -131,6 +131,7 @@ import org.apache.pulsar.broker.stats.prometheus.PulsarPrometheusMetricsServlet;
 import org.apache.pulsar.broker.storage.BookkeeperManagedLedgerStorageClass;
 import org.apache.pulsar.broker.storage.ManagedLedgerStorage;
 import org.apache.pulsar.broker.storage.ManagedLedgerStorageClass;
+import org.apache.pulsar.broker.tls.TlsFactorySupport;
 import org.apache.pulsar.broker.transaction.buffer.TransactionBufferProvider;
 import org.apache.pulsar.broker.transaction.buffer.impl.TransactionBufferClientImpl;
 import org.apache.pulsar.broker.transaction.coordinator.v5.TransactionCoordinatorV5;
@@ -148,6 +149,7 @@ import org.apache.pulsar.broker.web.plugin.servlet.AdditionalServletWithPulsarSe
 import org.apache.pulsar.broker.web.plugin.servlet.AdditionalServlets;
 import org.apache.pulsar.client.admin.PulsarAdmin;
 import org.apache.pulsar.client.admin.PulsarAdminBuilder;
+import org.apache.pulsar.client.admin.internal.PulsarAdminBuilderImpl;
 import org.apache.pulsar.client.api.AuthenticationFactory;
 import org.apache.pulsar.client.api.PulsarClient;
 import org.apache.pulsar.client.api.PulsarClientException;
@@ -156,6 +158,7 @@ import org.apache.pulsar.client.impl.DnsResolverGroupImpl;
 import org.apache.pulsar.client.impl.PulsarClientImpl;
 import org.apache.pulsar.client.impl.conf.ClientConfigurationData;
 import org.apache.pulsar.client.impl.conf.ConfigurationDataUtils;
+import org.apache.pulsar.client.impl.tls.ClientTlsFactorySupport;
 import org.apache.pulsar.client.internal.PropertiesUtils;
 import org.apache.pulsar.client.util.ExecutorProvider;
 import org.apache.pulsar.client.util.ScheduledExecutorProvider;
@@ -1847,8 +1850,12 @@ public class PulsarService implements AutoCloseable, ShutdownService {
     public PulsarClientImpl createClientImpl(ClientConfigurationData conf,
                                              Consumer<PulsarClientImpl.PulsarClientImplBuilder> customizer)
             throws PulsarClientException {
+        ClientConfigurationData clientConf = conf != null ? conf : createClientConfigurationData();
+        // PIP-478: route the broker's own outbound clients (replication + cluster-internal lookup)
+        // onto the new TLS SPI (BROKER_CLIENT) when opted in via brokerClientTlsFactoryClassName.
+        maybeApplyBrokerClientTlsFactory(clientConf);
         PulsarClientImpl.PulsarClientImplBuilder pulsarClientImplBuilder = PulsarClientImpl.builder()
-                .conf(conf != null ? conf : createClientConfigurationData())
+                .conf(clientConf)
                 .eventLoopGroup(ioEventLoopGroup)
                 .timer(brokerClientSharedTimer)
                 .internalExecutorProvider(brokerClientSharedInternalExecutorProvider)
@@ -1862,6 +1869,110 @@ public class PulsarService implements AutoCloseable, ShutdownService {
         return pulsarClientImplBuilder.build();
     }
 
+    /**
+     * Route the broker's own outbound Pulsar client (geo-replication and cluster-internal lookup) onto the
+     * new PIP-478 TLS SPI for the {@code BROKER_CLIENT} purpose, gated on the
+     * {@code brokerClientTlsFactoryClassName} opt-in. The broker-client material — per-cluster
+     * {@code ClusterData.brokerClientTls*} first, else the broker's {@code brokerClient*}
+     * {@code ServiceConfiguration} — is already mapped onto the config's {@code tls*} fields by the caller
+     * ({@code createClientConfigurationData} / {@code BrokerService.configTlsSettings}), so this only attaches
+     * a per-client {@link org.apache.pulsar.tls.PulsarTlsFactory} composed from those fields (folding
+     * the broker-client {@code Authentication} TLS material). Leaves the config on the legacy PIP-337 path
+     * when the gate is off, or when the outbound client is not TLS, or when it is already on the new path.
+     *
+     * @param conf the outbound client configuration (mutated to carry the TLS factory when opted in)
+     */
+    private void maybeApplyBrokerClientTlsFactory(ClientConfigurationData conf) {
+        String factoryClassName = brokerClientTlsFactorySelection(conf);
+        if (!isNotBlank(factoryClassName)) {
+            return;
+        }
+        if (conf.getTlsFactory() != null || conf.getTlsPolicyMap() != null || !conf.isUseTls()) {
+            return;
+        }
+        conf.setTlsFactory(ClientTlsFactorySupport.brokerClientTlsFactory(conf, factoryClassName));
+        // PIP-478: deliver brokerClientTlsFactoryConfig to a custom factory via the init context
+        // params (parsed the same way as the server-side tlsFactoryConfig).
+        conf.setTlsFactoryParams(TlsFactorySupport.parseFactoryConfig(brokerClientTlsFactoryConfig(conf)));
+    }
+
+    /**
+     * The {@code PulsarTlsFactory} class name for an outbound broker-client connection: the value
+     * {@code BrokerService} already resolved onto this configuration from the target
+     * {@link org.apache.pulsar.common.policies.data.ClusterData} when the cluster names one, else the
+     * broker-level {@code brokerClientTlsFactoryClassName} (PIP-478).
+     *
+     * <p>Reading the config rather than the {@code ServiceConfiguration} directly is what makes per-cluster
+     * selection work: a cluster entry that names its own factory must both win over the broker-level value
+     * <em>and</em> be routed through {@link ClientTlsFactorySupport#brokerClientTlsFactory}, which wraps a
+     * custom factory so it answers the transport's {@code CLIENT_DEFAULT} request from its
+     * {@code BROKER_CLIENT} material. Resolving it by name later, outside that wrapper, makes a compliant
+     * {@code BROKER_CLIENT}-only factory return empty and the connection fail.
+     *
+     * @param conf the outbound client configuration
+     * @return the selected factory class name, or blank when neither level selects one
+     */
+    private String brokerClientTlsFactorySelection(ClientConfigurationData conf) {
+        return isNotBlank(conf.getTlsFactoryClassName())
+                ? conf.getTlsFactoryClassName()
+                : getConfiguration().getBrokerClientTlsFactoryClassName();
+    }
+
+    /**
+     * The init params for {@link #brokerClientTlsFactorySelection}. The pair resolves <em>atomically</em>:
+     * when the cluster selects the factory class, its config wins even if blank, so factory A's parameters
+     * are never handed to factory B.
+     *
+     * @param conf the outbound client configuration
+     * @return the factory configuration string for the selected factory
+     */
+    private String brokerClientTlsFactoryConfig(ClientConfigurationData conf) {
+        return isNotBlank(conf.getTlsFactoryClassName())
+                ? conf.getTlsFactoryConfig()
+                : getConfiguration().getBrokerClientTlsFactoryConfig();
+    }
+
+    /**
+     * Route the broker's own outbound admin clients (the {@code PulsarAdmin} instances built by
+     * {@link #getCreateAdminClientBuilder} and {@code BrokerService.getClusterPulsarAdmin}) onto the new
+     * PIP-478 TLS SPI, gated on the same {@code brokerClientTlsFactoryClassName} opt-in as the
+     * binary path. The admin builder rides a {@link ClientConfigurationData} internally; this
+     * reaches it through {@link PulsarAdminBuilderImpl#getConf()} and attaches a per-client
+     * {@link org.apache.pulsar.tls.PulsarTlsFactory} composed from the broker-client {@code tls*}
+     * material already mapped onto the config (folding the broker-client {@code Authentication} TLS material).
+     * Ownership transfers with the configuration: the admin client initializes the factory and closes it when
+     * the admin closes, so this method must not retain or close it. (Concretely the owner is that admin's
+     * {@code AsyncHttpConnectorProvider}, which resolves one factory for all of the admin's connectors —
+     * see {@code TlsFactoryOwnership}.) Leaves the builder on the
+     * legacy PIP-337 path when the gate is off, when the admin URL is not TLS, or when it is already on the
+     * new path.
+     *
+     * @param builder the admin builder to route (a {@link PulsarAdminBuilderImpl}; other implementations are
+     *                left untouched)
+     */
+    public void applyBrokerClientTlsFactoryToAdmin(PulsarAdminBuilder builder) {
+        if (!(builder instanceof PulsarAdminBuilderImpl adminBuilder)) {
+            return;
+        }
+        ClientConfigurationData conf = adminBuilder.getConf();
+        String factoryClassName = brokerClientTlsFactorySelection(conf);
+        if (!isNotBlank(factoryClassName)) {
+            return;
+        }
+        if (conf.getTlsFactory() != null || conf.getTlsPolicyMap() != null) {
+            return;
+        }
+        // Admin traffic is HTTP; TLS is selected by an https service URL (there is no useTls flag on the
+        // admin builder path).
+        if (conf.getServiceUrl() == null || !conf.getServiceUrl().startsWith("https")) {
+            return;
+        }
+        conf.setTlsFactory(ClientTlsFactorySupport.brokerClientTlsFactory(conf, factoryClassName));
+        // PIP-478: deliver brokerClientTlsFactoryConfig to a custom factory via the init context
+        // params (parsed the same way as the server-side tlsFactoryConfig).
+        conf.setTlsFactoryParams(TlsFactorySupport.parseFactoryConfig(brokerClientTlsFactoryConfig(conf)));
+    }
+
     public synchronized PulsarClient getClient() throws PulsarServerException {
         if (this.client == null) {
             try {
@@ -1897,10 +2008,14 @@ public class PulsarService implements AutoCloseable, ShutdownService {
         if (tlsEnabled) {
             conf.setTlsCiphers(this.getConfiguration().getBrokerClientTlsCiphers());
             conf.setTlsProtocols(this.getConfiguration().getBrokerClientTlsProtocols());
+            // PIP-478: propagate the broker-client TLS engine (sslProvider) and JSSE (SSLContext) provider
+            // (jsseProvider) onto the internal client config so the broker's own outbound client honors them —
+            // both are documented as "used by the internal client" but were otherwise dropped here (never
+            // copied into ClientConfigurationData), silently defaulting the engine/provider.
+            conf.setSslProvider(this.getConfiguration().getBrokerClientSslProvider());
+            conf.setJsseProvider(this.getConfiguration().getBrokerClientJsseProvider());
             conf.setTlsAllowInsecureConnection(this.getConfiguration().isTlsAllowInsecureConnection());
             conf.setTlsHostnameVerificationEnable(this.getConfiguration().isTlsHostnameVerificationEnabled());
-            conf.setSslFactoryPlugin(this.getConfiguration().getBrokerClientSslFactoryPlugin());
-            conf.setSslFactoryPluginParams(this.getConfiguration().getBrokerClientSslFactoryPluginParams());
             if (this.getConfiguration().isBrokerClientTlsEnabledWithKeyStore()) {
                 conf.setUseKeyStoreTls(true);
                 conf.setTlsTrustStoreType(this.getConfiguration().getBrokerClientTlsTrustStoreType());
@@ -1974,9 +2089,7 @@ public class PulsarService implements AutoCloseable, ShutdownService {
 
         if (conf.isBrokerClientTlsEnabled()) {
             builder.tlsCiphers(conf.getBrokerClientTlsCiphers())
-                    .tlsProtocols(conf.getBrokerClientTlsProtocols())
-                    .sslFactoryPlugin(conf.getBrokerClientSslFactoryPlugin())
-                    .sslFactoryPluginParams(conf.getBrokerClientSslFactoryPluginParams());
+                    .tlsProtocols(conf.getBrokerClientTlsProtocols());
             if (conf.isBrokerClientTlsEnabledWithKeyStore()) {
                 builder.useKeyStoreTls(true).tlsTrustStoreType(conf.getBrokerClientTlsTrustStoreType())
                         .tlsTrustStorePath(conf.getBrokerClientTlsTrustStore())
@@ -1996,6 +2109,8 @@ public class PulsarService implements AutoCloseable, ShutdownService {
         // most of the admin request requires to make zk-call so, keep the max read-timeout based on
         // zk-operation timeout
         builder.readTimeout(conf.getMetadataStoreOperationTimeoutSeconds(), TimeUnit.SECONDS);
+        // PIP-478: route the broker's own admin client onto the new TLS SPI when opted in.
+        applyBrokerClientTlsFactoryToAdmin(builder);
         return builder;
     }
 
diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/namespace/NamespaceService.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/namespace/NamespaceService.java
index bc1a0e047a50..e77f8a173b81 100644
--- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/namespace/NamespaceService.java
+++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/namespace/NamespaceService.java
@@ -1727,6 +1727,19 @@ public class NamespaceService implements AutoCloseable {
                 .thenApply(GetTopicsResult::getTopics);
     }
 
+    /**
+     * The peer-cluster lookup client used to list a peer cluster's non-persistent topics. Only the service URL
+     * comes from the cluster entry: the TLS configuration is broker-level throughout — the material from the
+     * broker's own {@code brokerClient*} settings here, and the {@code PulsarTlsFactory} from the broker-level
+     * {@code brokerClientTlsFactoryClassName} via {@link PulsarService#createClientImpl}, since nothing sets
+     * {@code tlsFactoryClassName} on this configuration. So {@code ClusterData.brokerClientTls*} does not reach
+     * this leg — as in 4.x, where it read the broker-level {@code brokerClientSslFactoryPlugin} rather than the
+     * per-cluster one. The two legs the per-cluster fields do drive (replication and the cross-cluster admin)
+     * build their configuration from {@code ClusterData} in {@code BrokerService}.
+     *
+     * @param cluster the peer cluster to reach
+     * @return the shared client for that cluster
+     */
     @SuppressWarnings("deprecation")
     public PulsarClientImpl getNamespaceClient(ClusterDataImpl cluster) {
         PulsarClientImpl client = namespaceClients.get(cluster);
@@ -1761,9 +1774,7 @@ public class NamespaceService implements AutoCloseable {
                         .enableTls(true)
                         .tlsTrustCertsFilePath(pulsar.getConfiguration().getBrokerClientTrustCertsFilePath())
                         .allowTlsInsecureConnection(pulsar.getConfiguration().isTlsAllowInsecureConnection())
-                        .enableTlsHostnameVerification(pulsar.getConfiguration().isTlsHostnameVerificationEnabled())
-                        .sslFactoryPlugin(pulsar.getConfiguration().getBrokerClientSslFactoryPlugin())
-                        .sslFactoryPluginParams(pulsar.getConfiguration().getBrokerClientSslFactoryPluginParams());
+                        .enableTlsHostnameVerification(pulsar.getConfiguration().isTlsHostnameVerificationEnabled());
                 } else {
                     clientBuilder.serviceUrl(isNotBlank(cluster.getBrokerServiceUrl())
                         ? cluster.getBrokerServiceUrl() : cluster.getServiceUrl());
diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/BrokerService.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/BrokerService.java
index 51580be4bfd2..0019cf562c3e 100644
--- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/BrokerService.java
+++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/BrokerService.java
@@ -151,6 +151,7 @@ import org.apache.pulsar.broker.validator.BindAddressValidator;
 import org.apache.pulsar.client.admin.PulsarAdmin;
 import org.apache.pulsar.client.admin.PulsarAdminBuilder;
 import org.apache.pulsar.client.admin.PulsarAdminException;
+import org.apache.pulsar.client.admin.internal.PulsarAdminBuilderImpl;
 import org.apache.pulsar.client.api.ClientBuilder;
 import org.apache.pulsar.client.api.PulsarClient;
 import org.apache.pulsar.client.api.PulsarClientException;
@@ -237,6 +238,10 @@ public class BrokerService implements Closeable {
             ConcurrentHashMap.newKeySet();
 
     private final Map<String, PulsarClient> replicationClients = new ConcurrentHashMap<>();
+    // PIP-478: clusters already warned about a stale PIP-337 factory selection, so the WARN fires once.
+    private final Set<String> stalePip337ClusterFactoryWarned = ConcurrentHashMap.newKeySet();
+    // Clusters already warned about factory config that names no factory; the WARN fires once.
+    private final Set<String> staleClusterFactoryConfigWarned = ConcurrentHashMap.newKeySet();
     private final Map<String, PulsarAdmin> clusterAdmins = new ConcurrentHashMap<>();
 
     // Multi-layer topics map:
@@ -339,6 +344,8 @@ public class BrokerService implements Closeable {
     private PulsarChannelInitializer.Factory pulsarChannelInitFactory = PulsarChannelInitializer.DEFAULT_FACTORY;
 
     private final List<Channel> listenChannels = new ArrayList<>(2);
+    // PIP-478: the channel initializers that own a PulsarTlsFactory (new-SPI TLS path); closed on shutdown.
+    private final List<PulsarChannelInitializer> pulsarChannelInitializers = new ArrayList<>(2);
     private Channel listenChannel;
     private Channel listenChannelTls;
 
@@ -655,8 +662,10 @@ public class BrokerService implements Closeable {
                             .listenerName(a.getListenerName()).build();
 
             ServerBootstrap b = defaultServerBootstrap.clone();
-            b.childHandler(
-                    pulsarChannelInitFactory.newPulsarChannelInitializer(pulsar, opts));
+            PulsarChannelInitializer channelInitializer =
+                    pulsarChannelInitFactory.newPulsarChannelInitializer(pulsar, opts);
+            pulsarChannelInitializers.add(channelInitializer);
+            b.childHandler(channelInitializer);
             try {
                 Channel ch = b.bind(addr).sync().channel();
                 listenChannels.add(ch);
@@ -1005,6 +1014,9 @@ public class BrokerService implements Closeable {
                                     }
                                 });
 
+                                // PIP-478: dispose any PulsarTlsFactory subscriptions held by the listeners.
+                                pulsarChannelInitializers.forEach(PulsarChannelInitializer::close);
+
                                 maxTopicListInFlightLimiter.close();
 
                                 if (interceptor != null) {
@@ -1632,7 +1644,6 @@ public class BrokerService implements Closeable {
         return topicFuture;
     }
 
-    @SuppressWarnings("deprecation")
     public PulsarClient getReplicationClient(String cluster, Optional<ClusterData> clusterDataOp) {
         PulsarClient client = replicationClients.get(cluster);
         if (client != null) {
@@ -1643,6 +1654,7 @@ public class BrokerService implements Closeable {
             try {
                 ClusterData data = clusterDataOp
                         .orElseThrow(() -> new MetadataStoreException.NotFoundException(cluster));
+                warnOnStalePip337ClusterFactory(cluster, data);
                 ClientBuilder clientBuilder = PulsarClient.builder()
                         .enableTcpNoDelay(false)
                         .connectionsPerBroker(pulsar.getConfiguration().getReplicationConnectionsPerBroker())
@@ -1682,8 +1694,12 @@ public class BrokerService implements Closeable {
                             data.getBrokerClientKeyFilePath(),
                             data.getBrokerClientCertificateFilePath(),
                             pulsar.getConfiguration().isTlsHostnameVerificationEnabled(),
-                            data.getBrokerClientSslFactoryPlugin(),
-                            data.getBrokerClientSslFactoryPluginParams()
+                            resolveBrokerClientTlsFactory(data.getBrokerClientTlsFactoryClassName(),
+                                    pulsar.getConfiguration().getBrokerClientTlsFactoryClassName()),
+                            resolveBrokerClientTlsFactoryConfig(cluster,
+                                    data.getBrokerClientTlsFactoryClassName(),
+                                    data.getBrokerClientTlsFactoryConfig(),
+                                    pulsar.getConfiguration().getBrokerClientTlsFactoryConfig())
                     );
                 } else if (pulsar.getConfiguration().isBrokerClientTlsEnabled()) {
                     configTlsSettings(clientBuilder, serviceUrlTls,
@@ -1699,8 +1715,12 @@ public class BrokerService implements Closeable {
                             pulsar.getConfiguration().getBrokerClientKeyFilePath(),
                             pulsar.getConfiguration().getBrokerClientCertificateFilePath(),
                             pulsar.getConfiguration().isTlsHostnameVerificationEnabled(),
-                            pulsar.getConfiguration().getBrokerClientSslFactoryPlugin(),
-                            pulsar.getConfiguration().getBrokerClientSslFactoryPluginParams()
+                            resolveBrokerClientTlsFactory(data.getBrokerClientTlsFactoryClassName(),
+                                    pulsar.getConfiguration().getBrokerClientTlsFactoryClassName()),
+                            resolveBrokerClientTlsFactoryConfig(cluster,
+                                    data.getBrokerClientTlsFactoryClassName(),
+                                    data.getBrokerClientTlsFactoryConfig(),
+                                    pulsar.getConfiguration().getBrokerClientTlsFactoryConfig())
                     );
                 } else {
                     clientBuilder.serviceUrl(
@@ -1726,6 +1746,99 @@ public class BrokerService implements Closeable {
         });
     }
 
+    /**
+     * Resolve the {@code PulsarTlsFactory} class name for outbound connections to a remote cluster: the
+     * cluster's own {@code brokerClientTlsFactoryClassName} when set, else the broker-level one (PIP-478).
+     *
+     * <p>Unlike the TLS <em>material</em> in a {@link ClusterData} entry — which is taken wholesale, so a
+     * cluster that enables broker-client TLS supplies all of its own certificates — the factory falls back.
+     * The factory selects the <em>mechanism</em> that loads material rather than the material itself, and a
+     * deployment that configured a custom broker-client factory (an HSM-backed one, say) must not silently
+     * revert to the default file-based factory just because a cluster entry did not repeat the setting.
+     * That is the same reasoning that makes {@code brokerClientSslProvider} / {@code brokerClientJsseProvider}
+     * broker-level in these helpers: a silent downgrade of the TLS mechanism is a security regression.
+     *
+     * @param clusterValue the cluster's value, possibly blank
+     * @param brokerValue  the broker-level value
+     * @return the cluster value when non-blank, else the broker-level value
+     */
+    /**
+     * Log the stale PIP-337 per-cluster factory selection, once per cluster (PIP-478). This is the single
+     * removed PIP-337 key whose stale value cannot fail loud — rejecting it on a metadata read would make the
+     * cluster unloadable — so a WARN naming the cluster is the whole remediation signal, and both
+     * {@code ClusterData}'s deprecation javadoc and pip-478.md promise it.
+     *
+     * @param cluster the cluster name, for the operator to act on
+     * @param data    the cluster metadata being consumed
+     */
+    @SuppressWarnings("deprecation")
+    private void warnOnStalePip337ClusterFactory(String cluster, ClusterData data) {
+        if (StringUtils.isBlank(data.getBrokerClientSslFactoryPlugin())
+                && StringUtils.isBlank(data.getBrokerClientSslFactoryPluginParams())) {
+            return;
+        }
+        if (!stalePip337ClusterFactoryWarned.add(cluster)) {
+            return;
+        }
+        log.warn()
+                .attr("cluster", cluster)
+                .attr("brokerClientSslFactoryPlugin", data.getBrokerClientSslFactoryPlugin())
+                .attr("brokerClientSslFactoryPluginParams", data.getBrokerClientSslFactoryPluginParams())
+                .log("Ignoring the PIP-337 per-cluster SSL factory settings: it was removed in Pulsar 5.0 "
+                        + "(PIP-478). Set ClusterData.brokerClientTlsFactoryClassName (per cluster) or the "
+                        + "broker-level brokerClientTlsFactoryClassName instead");
+    }
+
+    /**
+     * Resolve the {@code PulsarTlsFactory} class name for outbound connections to a remote cluster: the
+     * cluster's own {@code brokerClientTlsFactoryClassName} when set, else the broker-level one (PIP-478).
+     *
+     * <p>Unlike the TLS <em>material</em> in a {@link ClusterData} entry — taken wholesale, so a cluster that
+     * enables broker-client TLS supplies all of its own certificates — the factory falls back. The factory
+     * selects the <em>mechanism</em> that loads material rather than the material itself, and a deployment
+     * that configured a custom broker-client factory (an HSM-backed one, say) must not silently revert to the
+     * default file-based factory because a cluster entry did not repeat the setting. That is the same
+     * reasoning that keeps {@code brokerClientSslProvider} / {@code brokerClientJsseProvider} broker-level in
+     * these helpers: a silent downgrade of the TLS mechanism is a security regression.
+     *
+     * @param clusterValue the cluster's value, possibly blank
+     * @param brokerValue  the broker-level value
+     * @return the cluster value when non-blank, else the broker-level value
+     */
+    private static String resolveBrokerClientTlsFactory(String clusterValue, String brokerValue) {
+        return StringUtils.isNotBlank(clusterValue) ? clusterValue : brokerValue;
+    }
+
+    /**
+     * The factory configuration that belongs with {@link #resolveBrokerClientTlsFactory}'s class name. The
+     * pair resolves <em>atomically</em>: when the cluster names the factory, the cluster's config wins even
+     * when blank. Resolving the two independently would hand factory A's init parameters to factory B — a
+     * cluster that overrides only the class name would otherwise inherit the broker-level config.
+     *
+     * @param clusterClassName the cluster's factory class name, possibly blank
+     * @param clusterConfig    the cluster's factory configuration, possibly blank
+     * @param brokerConfig     the broker-level factory configuration
+     * @return the configuration belonging to the selected factory
+     */
+    private String resolveBrokerClientTlsFactoryConfig(String cluster, String clusterClassName,
+                                                       String clusterConfig, String brokerConfig) {
+        if (StringUtils.isNotBlank(clusterClassName)) {
+            return clusterConfig;
+        }
+        if (StringUtils.isNotBlank(clusterConfig) && staleClusterFactoryConfigWarned.add(cluster)) {
+            // The cluster configured init params but named no factory, so the broker-level factory applies and
+            // these params are not its. Dropping them silently is the same failure shape as a stale PIP-337
+            // value, so say so rather than let the operator believe they took effect.
+            log.warn()
+                    .attr("cluster", cluster)
+                    .log("Ignoring ClusterData.brokerClientTlsFactoryConfig: the cluster sets no "
+                            + "brokerClientTlsFactoryClassName, so the broker-level factory and its own "
+                            + "configuration apply. Set the class name on the cluster to use this "
+                            + "configuration");
+        }
+        return brokerConfig;
+    }
+
     private void configTlsSettings(ClientBuilder clientBuilder, String serviceUrl,
                                    boolean brokerClientTlsEnabledWithKeyStore, boolean isTlsAllowInsecureConnection,
                                    String brokerClientTlsTrustStoreType, String brokerClientTlsTrustStore,
@@ -1733,16 +1846,28 @@ public class BrokerService implements Closeable {
                                    String brokerClientTlsKeyStore, String brokerClientTlsKeyStorePassword,
                                    String brokerClientTrustCertsFilePath,
                                    String brokerClientKeyFilePath, String brokerClientCertificateFilePath,
-                                   boolean isTlsHostnameVerificationEnabled, String brokerClientSslFactoryPlugin,
-                                   String brokerClientSslFactoryPluginParams) {
+                                   boolean isTlsHostnameVerificationEnabled,
+                                   String tlsFactoryClassName, String tlsFactoryConfig) {
+        // PIP-478: the PIP-337 sslFactoryPlugin config is removed. A stale broker-level key with a
+        // non-default value is rejected at config-file load by PulsarConfigurationLoader; the
+        // per-cluster ClusterData field is retained but ignored, and warnOnStalePip337ClusterFactory
+        // logs it (the one place a stale value cannot fail loud). Its successor is
+        // ClusterData.brokerClientTlsFactoryClassName, resolved by
+        // resolveBrokerClientTlsFactory below.
         clientBuilder
                 .serviceUrl(serviceUrl)
                 .allowTlsInsecureConnection(isTlsAllowInsecureConnection)
-                .enableTlsHostnameVerification(isTlsHostnameVerificationEnabled);
-        if (StringUtils.isNotBlank(brokerClientSslFactoryPlugin)) {
-            clientBuilder.sslFactoryPlugin(brokerClientSslFactoryPlugin)
-                    .sslFactoryPluginParams(brokerClientSslFactoryPluginParams);
-        }
+                .enableTlsHostnameVerification(isTlsHostnameVerificationEnabled)
+                .tlsFactoryClassName(tlsFactoryClassName)
+                .tlsFactoryConfig(tlsFactoryConfig)
+                // PIP-478: propagate the broker-client TLS engine (sslProvider) and JSSE (SSLContext) provider
+                // (jsseProvider) so geo-replication clients honor them, mirroring
+                // PulsarService.createClientConfigurationData. Without this the replication client silently
+                // builds TLS with the default JDK provider (e.g. a FIPS/OpenSSL provider downgrade).
+                .sslProvider(pulsar.getConfiguration().getBrokerClientSslProvider());
+        // jsseProvider has no ClientBuilder setter; set it on the underlying config like sslProvider above.
+        ((ClientBuilderImpl) clientBuilder).getClientConfigurationData()
+                .setJsseProvider(pulsar.getConfiguration().getBrokerClientJsseProvider());
         if (brokerClientTlsEnabledWithKeyStore) {
             clientBuilder.useKeyStoreTls(true)
                     .tlsTrustStoreType(brokerClientTlsTrustStoreType)
@@ -1765,8 +1890,13 @@ public class BrokerService implements Closeable {
                                         String brokerClientTlsKeyStore, String brokerClientTlsKeyStorePassword,
                                         String brokerClientTrustCertsFilePath,
                                         String brokerClientKeyFilePath, String brokerClientCertificateFilePath,
-                                        boolean isTlsHostnameVerificationEnabled, String brokerClientSslFactoryPlugin,
-                                        String brokerClientSslFactoryPluginParams) {
+                                        boolean isTlsHostnameVerificationEnabled,
+                                        String tlsFactoryClassName, String tlsFactoryConfig) {
+        // PIP-478: the PIP-337 sslFactoryPlugin config is removed (see configTlsSettings). The cluster's
+        // brokerClientTlsFactoryClassName covers this "purpose" too: a ClusterData entry selects one factory
+        // for both outbound legs it drives — the binary replication client and this admin (HTTPS) client.
+        adminBuilder.tlsFactoryClassName(tlsFactoryClassName)
+                .tlsFactoryConfig(tlsFactoryConfig);
         if (brokerClientTlsEnabledWithKeyStore) {
             adminBuilder.useKeyStoreTls(true)
                     .tlsTrustStoreType(brokerClientTlsTrustStoreType)
@@ -1782,8 +1912,14 @@ public class BrokerService implements Closeable {
         }
         adminBuilder.allowTlsInsecureConnection(isTlsAllowInsecureConnection)
                 .enableTlsHostnameVerification(isTlsHostnameVerificationEnabled)
-                .sslFactoryPlugin(brokerClientSslFactoryPlugin)
-                .sslFactoryPluginParams(brokerClientSslFactoryPluginParams);
+                // PIP-478: propagate the broker-client TLS engine (sslProvider) and JSSE (SSLContext) provider
+                // (jsseProvider) so cross-cluster admin clients honor them, mirroring
+                // PulsarService.createClientConfigurationData. Without this the cluster admin silently builds
+                // TLS with the default JDK provider (e.g. a FIPS/OpenSSL provider downgrade).
+                .sslProvider(pulsar.getConfiguration().getBrokerClientSslProvider());
+        // jsseProvider has no PulsarAdminBuilder setter; set it on the underlying config like sslProvider.
+        ((PulsarAdminBuilderImpl) adminBuilder).getConf()
+                .setJsseProvider(pulsar.getConfiguration().getBrokerClientJsseProvider());
     }
 
     public PulsarAdmin getClusterPulsarAdmin(String cluster, Optional<ClusterData> clusterDataOp) {
@@ -1795,6 +1931,7 @@ public class BrokerService implements Closeable {
             try {
                 ClusterData data = clusterDataOp
                         .orElseThrow(() -> new MetadataStoreException.NotFoundException(cluster));
+                warnOnStalePip337ClusterFactory(cluster, data);
                 PulsarAdminBuilder builder = PulsarAdmin.builder();
 
                 ServiceConfiguration conf = pulsar.getConfig();
@@ -1831,8 +1968,12 @@ public class BrokerService implements Closeable {
                             data.getBrokerClientKeyFilePath(),
                             data.getBrokerClientCertificateFilePath(),
                             pulsar.getConfiguration().isTlsHostnameVerificationEnabled(),
-                            data.getBrokerClientSslFactoryPlugin(),
-                            data.getBrokerClientSslFactoryPluginParams()
+                            resolveBrokerClientTlsFactory(data.getBrokerClientTlsFactoryClassName(),
+                                    conf.getBrokerClientTlsFactoryClassName()),
+                            resolveBrokerClientTlsFactoryConfig(cluster,
+                                    data.getBrokerClientTlsFactoryClassName(),
+                                    data.getBrokerClientTlsFactoryConfig(),
+                                    conf.getBrokerClientTlsFactoryConfig())
                     );
                 } else if (conf.isBrokerClientTlsEnabled()) {
                     configAdminTlsSettings(builder,
@@ -1848,8 +1989,12 @@ public class BrokerService implements Closeable {
                             conf.getBrokerClientKeyFilePath(),
                             conf.getBrokerClientCertificateFilePath(),
                             pulsar.getConfiguration().isTlsHostnameVerificationEnabled(),
-                            conf.getBrokerClientSslFactoryPlugin(),
-                            conf.getBrokerClientSslFactoryPluginParams()
+                            resolveBrokerClientTlsFactory(data.getBrokerClientTlsFactoryClassName(),
+                                    conf.getBrokerClientTlsFactoryClassName()),
+                            resolveBrokerClientTlsFactoryConfig(cluster,
+                                    data.getBrokerClientTlsFactoryClassName(),
+                                    data.getBrokerClientTlsFactoryConfig(),
+                                    conf.getBrokerClientTlsFactoryConfig())
                     );
                 }
 
@@ -1857,6 +2002,9 @@ public class BrokerService implements Closeable {
                 // zk-operation timeout
                 builder.readTimeout(conf.getMetadataStoreOperationTimeoutSeconds(), TimeUnit.SECONDS);
 
+                // PIP-478: route this per-cluster admin client onto the new TLS SPI when opted in.
+                pulsar.applyBrokerClientTlsFactoryToAdmin(builder);
+
                 PulsarAdmin adminClient = builder.build();
                 log.info().attr("adminApiUrl", adminApiUrl).log("Created client admin instance");
                 return adminClient;
diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/PulsarChannelInitializer.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/PulsarChannelInitializer.java
index 579e253f8807..7c4de4013c53 100644
--- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/PulsarChannelInitializer.java
+++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/PulsarChannelInitializer.java
@@ -23,19 +23,24 @@ import io.netty.channel.ChannelHandler;
 import io.netty.channel.ChannelInitializer;
 import io.netty.channel.socket.SocketChannel;
 import io.netty.handler.flush.FlushConsolidationHandler;
-import io.netty.handler.ssl.SslHandler;
-import java.util.concurrent.TimeUnit;
+import io.netty.handler.ssl.SslContext;
 import lombok.Builder;
 import lombok.CustomLog;
 import lombok.Data;
 import org.apache.pulsar.broker.PulsarService;
 import org.apache.pulsar.broker.ServiceConfiguration;
+import org.apache.pulsar.broker.tls.DefaultBrokerTlsFactory;
+import org.apache.pulsar.broker.tls.TlsFactorySupport;
 import org.apache.pulsar.common.protocol.ByteBufPair;
 import org.apache.pulsar.common.protocol.FrameDecoderUtil;
 import org.apache.pulsar.common.protocol.OptionalProxyProtocolDecoder;
-import org.apache.pulsar.common.util.PulsarSslConfiguration;
-import org.apache.pulsar.common.util.PulsarSslFactory;
+import org.apache.pulsar.common.tls.impl.TlsContextAcquisition;
+import org.apache.pulsar.common.tls.impl.TlsSynthesisSpec;
 import org.apache.pulsar.common.util.netty.PulsarFlowControlHandler;
+import org.apache.pulsar.tls.PulsarTlsFactory;
+import org.apache.pulsar.tls.TlsFactoryInitContext;
+import org.apache.pulsar.tls.TlsHandle;
+import org.apache.pulsar.tls.TlsPurpose;
 
 @CustomLog
 public class PulsarChannelInitializer extends ChannelInitializer<SocketChannel> {
@@ -46,7 +51,10 @@ public class PulsarChannelInitializer extends ChannelInitializer<SocketChannel>
     private final String listenerName;
     private final boolean enableTls;
     private final ServiceConfiguration brokerConf;
-    private PulsarSslFactory sslFactory;
+    // PIP-478 TLS SPI factory (the only server TLS path since PIP-337 removal).
+    private PulsarTlsFactory tlsFactory;
+    private TlsHandle<SslContext> tlsSubscription;
+    private volatile SslContext tlsServerContext;
 
     /**
      * @param pulsar
@@ -61,21 +69,53 @@ public class PulsarChannelInitializer extends ChannelInitializer<SocketChannel>
         this.enableTls = opts.isEnableTLS();
         ServiceConfiguration serviceConfig = pulsar.getConfiguration();
         if (this.enableTls) {
-            PulsarSslConfiguration pulsarSslConfig = buildSslConfiguration(serviceConfig);
-            this.sslFactory = (PulsarSslFactory) Class.forName(serviceConfig.getSslFactoryPlugin())
-                    .getConstructor().newInstance();
-            this.sslFactory.initialize(pulsarSslConfig);
-            this.sslFactory.createInternalSslContext();
-            if (serviceConfig.getTlsCertRefreshCheckDurationSec() > 0) {
-                this.pulsar.getExecutor().scheduleWithFixedDelay(this::refreshSslContext,
-                        serviceConfig.getTlsCertRefreshCheckDurationSec(),
-                        serviceConfig.getTlsCertRefreshCheckDurationSec(),
-                        TimeUnit.SECONDS);
-            }
+            initializeTlsFactory(serviceConfig);
         }
         this.brokerConf = pulsar.getConfiguration();
     }
 
+    // PIP-478: build the PulsarTlsFactory, initialize it, and subscribe to the BROKER purpose. A volatile
+    // Netty SslContext holds the latest instance; rotation is delivered by the subscription (no refresh
+    // task). The subscription's first delivery happens-before its future completes, so tlsServerContext is
+    // set by the time this returns.
+    private void initializeTlsFactory(ServiceConfiguration serviceConfig) throws Exception {
+        this.tlsFactory = TlsFactorySupport.createFactory(serviceConfig.getTlsFactoryClassName(),
+                DefaultBrokerTlsFactory.class,
+                () -> DefaultBrokerTlsFactory.fromServiceConfiguration(serviceConfig));
+        try {
+            TlsFactoryInitContext initContext = TlsFactorySupport.initContext(
+                    TlsFactorySupport.parseFactoryConfig(serviceConfig.getTlsFactoryConfig()),
+                    pulsar.getExecutor(), pulsar.getExecutor(), pulsar.getOpenTelemetry().getOpenTelemetry());
+            TlsFactorySupport.initializeBlocking(this.tlsFactory, initContext);
+            this.tlsSubscription = TlsContextAcquisition.acquireNettyContext(this.tlsFactory, TlsPurpose.BROKER,
+                            TlsSynthesisSpec.server(serviceConfig.isTlsRequireTrustedClientCertOnConnect()),
+                            ctx -> this.tlsServerContext = ctx)
+                    .get()
+                    .orElseThrow(() -> new IllegalStateException(
+                            "TLS factory supplied no Netty SslContext for purpose " + TlsPurpose.BROKER));
+        } catch (Exception e) {
+            // Ctor-throw cleanup: the factory was created (and possibly initialized, its rotation
+            // scheduler running) but the subscription failed; close() will not be called on a half-constructed
+            // initializer, so release it here. close() is null-safe and idempotent.
+            close();
+            throw e;
+        }
+    }
+
+    /** Dispose the PIP-478 TLS factory and its subscription, if any. Safe to call more than once. */
+    public void close() {
+        TlsHandle<SslContext> subscription = this.tlsSubscription;
+        if (subscription != null) {
+            this.tlsSubscription = null;
+            subscription.dispose();
+        }
+        PulsarTlsFactory factory = this.tlsFactory;
+        if (factory != null) {
+            this.tlsFactory = null;
+            factory.close();
+        }
+    }
+
     @Override
     protected void initChannel(SocketChannel ch) throws Exception {
         // disable auto read explicitly so that requests aren't served until auto read is enabled
@@ -85,7 +125,11 @@ public class PulsarChannelInitializer extends ChannelInitializer<SocketChannel>
         ch.config().setWriteBufferLowWaterMark(pulsar.getConfig().getPulsarChannelWriteBufferLowWaterMark());
         ch.pipeline().addLast("consolidation", new FlushConsolidationHandler(1024, true));
         if (this.enableTls) {
-            ch.pipeline().addLast(TLS_HANDLER, new SslHandler(this.sslFactory.createServerSslEngine(ch.alloc())));
+            // PIP-478: build the handler from the current (possibly rotated) factory-owned SslContext, pinning
+            // it across newHandler so a concurrent rotation cannot free the native OpenSSL context mid-build
+            // (use-after-free guard).
+            ch.pipeline().addLast(TLS_HANDLER, TlsContextAcquisition.withPinnedContext(
+                    () -> this.tlsServerContext, ctx -> ctx.newHandler(ch.alloc())));
         }
         ch.pipeline().addLast("ByteBufPairEncoder", ByteBufPair.getEncoder(this.enableTls));
 
@@ -133,33 +177,4 @@ public class PulsarChannelInitializer extends ChannelInitializer<SocketChannel>
          */
         private String listenerName;
     }
-
-    protected PulsarSslConfiguration buildSslConfiguration(ServiceConfiguration serviceConfig) {
-        return PulsarSslConfiguration.builder()
-                .tlsKeyStoreType(serviceConfig.getTlsKeyStoreType())
-                .tlsKeyStorePath(serviceConfig.getTlsKeyStore())
-                .tlsKeyStorePassword(serviceConfig.getTlsKeyStorePassword())
-                .tlsTrustStoreType(serviceConfig.getTlsTrustStoreType())
-                .tlsTrustStorePath(serviceConfig.getTlsTrustStore())
-                .tlsTrustStorePassword(serviceConfig.getTlsTrustStorePassword())
-                .tlsCiphers(serviceConfig.getTlsCiphers())
-                .tlsProtocols(serviceConfig.getTlsProtocols())
-                .tlsTrustCertsFilePath(serviceConfig.getTlsTrustCertsFilePath())
-                .tlsCertificateFilePath(serviceConfig.getTlsCertificateFilePath())
-                .tlsKeyFilePath(serviceConfig.getTlsKeyFilePath())
-                .allowInsecureConnection(serviceConfig.isTlsAllowInsecureConnection())
-                .requireTrustedClientCertOnConnect(serviceConfig.isTlsRequireTrustedClientCertOnConnect())
-                .tlsEnabledWithKeystore(serviceConfig.isTlsEnabledWithKeyStore())
-                .tlsCustomParams(serviceConfig.getSslFactoryPluginParams())
-                .serverMode(true)
-                .build();
-    }
-
-    protected void refreshSslContext() {
-        try {
-            this.sslFactory.update();
-        } catch (Exception e) {
-            log.error().exception(e).log("Failed to refresh SSL context");
-        }
-    }
 }
diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/web/WebService.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/web/WebService.java
index 7f576e7d19b1..908943b89f75 100644
--- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/web/WebService.java
+++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/web/WebService.java
@@ -37,8 +37,6 @@ import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.concurrent.ExecutionException;
-import java.util.concurrent.ScheduledFuture;
-import java.util.concurrent.TimeUnit;
 import lombok.CustomLog;
 import lombok.Getter;
 import org.apache.commons.lang3.StringUtils;
@@ -47,12 +45,15 @@ import org.apache.pulsar.broker.PulsarService;
 import org.apache.pulsar.broker.ServiceConfiguration;
 import org.apache.pulsar.broker.intercept.BrokerInterceptor;
 import org.apache.pulsar.broker.intercept.BrokerInterceptors;
+import org.apache.pulsar.broker.tls.DefaultBrokerTlsFactory;
+import org.apache.pulsar.broker.tls.TlsFactorySupport;
 import org.apache.pulsar.broker.validator.BindAddressValidator;
 import org.apache.pulsar.common.configuration.BindAddress;
-import org.apache.pulsar.common.util.PulsarSslConfiguration;
-import org.apache.pulsar.common.util.PulsarSslFactory;
 import org.apache.pulsar.jetty.metrics.JettyStatisticsCollector;
-import org.apache.pulsar.jetty.tls.JettySslContextFactory;
+import org.apache.pulsar.jetty.tls.JettyTlsFactory;
+import org.apache.pulsar.tls.PulsarTlsFactory;
+import org.apache.pulsar.tls.TlsFactoryInitContext;
+import org.apache.pulsar.tls.TlsPurpose;
 import org.eclipse.jetty.ee10.servlet.FilterHolder;
 import org.eclipse.jetty.ee10.servlet.ServletContextHandler;
 import org.eclipse.jetty.ee10.servlet.ServletContextRequest;
@@ -109,8 +110,9 @@ public class WebService implements AutoCloseable {
     private final ServerConnector httpsConnector;
     private final FilterInitializer filterInitializer;
     private JettyStatisticsCollector jettyStatisticsCollector;
-    private PulsarSslFactory sslFactory;
-    private ScheduledFuture<?> sslContextRefreshTask;
+    // PIP-478 TLS SPI factory (the only server TLS path since PIP-337 removal).
+    private PulsarTlsFactory tlsFactory;
+    private JettyTlsFactory.ReloadableServerTls reloadableServerTls;
 
     @Getter
     private static final DynamicSkipUnknownPropertyHandler sharedUnknownPropertyHandler =
@@ -163,21 +165,7 @@ public class WebService implements AutoCloseable {
         SslContextFactory.Server sslCtxFactory = null;
         if (tlsRequired) {
             try {
-                PulsarSslConfiguration sslConfiguration = buildSslConfiguration(config);
-                this.sslFactory = (PulsarSslFactory) Class.forName(config.getSslFactoryPlugin())
-                        .getConstructor().newInstance();
-                this.sslFactory.initialize(sslConfiguration);
-                this.sslFactory.createInternalSslContext();
-                if (config.getTlsCertRefreshCheckDurationSec() > 0) {
-                    this.sslContextRefreshTask = this.pulsar.getExecutor()
-                            .scheduleWithFixedDelay(this::refreshSslContext,
-                                    config.getTlsCertRefreshCheckDurationSec(),
-                                    config.getTlsCertRefreshCheckDurationSec(),
-                                    TimeUnit.SECONDS);
-                }
-                sslCtxFactory = JettySslContextFactory.createSslContextFactory(config.getWebServiceTlsProvider(),
-                        this.sslFactory, config.isTlsRequireTrustedClientCertOnConnect(),
-                        config.getTlsCiphers(), config.getTlsProtocols());
+                sslCtxFactory = createTlsFactoryWebServer(config);
             } catch (Exception e) {
                 throw new PulsarServerException(e);
             }
@@ -569,8 +557,14 @@ public class WebService implements AutoCloseable {
             jettyStatisticsCollector = null;
         }
         webServiceExecutor.join();
-        if (this.sslContextRefreshTask != null) {
-            this.sslContextRefreshTask.cancel(true);
+        // PIP-478: dispose the TLS factory subscription and close the factory, if the new path was used.
+        if (this.reloadableServerTls != null) {
+            this.reloadableServerTls.subscription().dispose();
+            this.reloadableServerTls = null;
+        }
+        if (this.tlsFactory != null) {
+            this.tlsFactory.close();
+            this.tlsFactory = null;
         }
         webExecutorThreadPoolStats.close();
         this.executorStats.close();
@@ -593,33 +587,35 @@ public class WebService implements AutoCloseable {
         }
     }
 
-    protected PulsarSslConfiguration buildSslConfiguration(ServiceConfiguration serviceConfig) {
-        return PulsarSslConfiguration.builder()
-                .tlsKeyStoreType(serviceConfig.getTlsKeyStoreType())
-                .tlsKeyStorePath(serviceConfig.getTlsKeyStore())
-                .tlsKeyStorePassword(serviceConfig.getTlsKeyStorePassword())
-                .tlsTrustStoreType(serviceConfig.getTlsTrustStoreType())
-                .tlsTrustStorePath(serviceConfig.getTlsTrustStore())
-                .tlsTrustStorePassword(serviceConfig.getTlsTrustStorePassword())
-                .tlsCiphers(serviceConfig.getTlsCiphers())
-                .tlsProtocols(serviceConfig.getTlsProtocols())
-                .tlsTrustCertsFilePath(serviceConfig.getTlsTrustCertsFilePath())
-                .tlsCertificateFilePath(serviceConfig.getTlsCertificateFilePath())
-                .tlsKeyFilePath(serviceConfig.getTlsKeyFilePath())
-                .allowInsecureConnection(serviceConfig.isTlsAllowInsecureConnection())
-                .requireTrustedClientCertOnConnect(serviceConfig.isTlsRequireTrustedClientCertOnConnect())
-                .tlsEnabledWithKeystore(serviceConfig.isTlsEnabledWithKeyStore())
-                .tlsCustomParams(serviceConfig.getSslFactoryPluginParams())
-                .serverMode(true)
-                .isHttps(true)
-                .build();
-    }
-
-    protected void refreshSslContext() {
+    // PIP-478: build the PulsarTlsFactory and drive a vanilla Jetty SslContextFactory.Server through the
+    // SSLContext subscription (setSslContext pre-start, reload() on rotation). The web server has no cert
+    // refresh task — the factory delivers rotations to the reloading server factory.
+    private SslContextFactory.Server createTlsFactoryWebServer(ServiceConfiguration config) throws Exception {
+        this.tlsFactory = TlsFactorySupport.createFactory(config.getTlsFactoryClassName(),
+                DefaultBrokerTlsFactory.class, () -> DefaultBrokerTlsFactory.fromServiceConfiguration(config));
+        // Once the factory is created it owns live resources (cert watchers, HSM sessions, reload work).
+        // A failure in any subsequent step rethrows out of the constructor without returning a WebService,
+        // so doClose() is never reachable — dispose/close the partial state here to avoid leaking it.
         try {
-            this.sslFactory.update();
+            TlsFactoryInitContext initContext = TlsFactorySupport.initContext(
+                    TlsFactorySupport.parseFactoryConfig(config.getTlsFactoryConfig()),
+                    pulsar.getExecutor(), pulsar.getExecutor(), pulsar.getOpenTelemetry().getOpenTelemetry());
+            TlsFactorySupport.initializeBlocking(this.tlsFactory, initContext);
+            this.reloadableServerTls = JettyTlsFactory.createReloadingServerFactory(this.tlsFactory, TlsPurpose.WEB,
+                    pulsar.getExecutor(),
+                    config.getWebServiceTlsProvider(), config.isTlsRequireTrustedClientCertOnConnect(),
+                    config.isTlsAllowInsecureConnection(), config.getTlsCiphers(), config.getTlsProtocols());
+            return this.reloadableServerTls.sslContextFactory();
         } catch (Exception e) {
-            log.error().exception(e).log("Failed to refresh SSL context");
+            if (this.reloadableServerTls != null) {
+                this.reloadableServerTls.subscription().dispose();
+                this.reloadableServerTls = null;
+            }
+            if (this.tlsFactory != null) {
+                this.tlsFactory.close();
+                this.tlsFactory = null;
+            }
+            throw e;
         }
     }
 }
diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/compaction/CompactorTool.java b/pulsar-broker/src/main/java/org/apache/pulsar/compaction/CompactorTool.java
index 5413b10858fa..93a0b38682aa 100644
--- a/pulsar-broker/src/main/java/org/apache/pulsar/compaction/CompactorTool.java
+++ b/pulsar-broker/src/main/java/org/apache/pulsar/compaction/CompactorTool.java
@@ -95,9 +95,7 @@ public class CompactorTool {
         if (internalListener.getBrokerServiceUrlTls() != null && brokerConfig.isBrokerClientTlsEnabled()) {
             clientBuilder.serviceUrl(internalListener.getBrokerServiceUrlTls().toString())
                     .allowTlsInsecureConnection(brokerConfig.isTlsAllowInsecureConnection())
-                    .enableTlsHostnameVerification(brokerConfig.isTlsHostnameVerificationEnabled())
-                    .sslFactoryPlugin(brokerConfig.getBrokerClientSslFactoryPlugin())
-                    .sslFactoryPluginParams(brokerConfig.getBrokerClientSslFactoryPluginParams());
+                    .enableTlsHostnameVerification(brokerConfig.isTlsHostnameVerificationEnabled());
             if (brokerConfig.isBrokerClientTlsEnabledWithKeyStore()) {
                 clientBuilder.useKeyStoreTls(true)
                         .tlsKeyStoreType(brokerConfig.getBrokerClientTlsKeyStoreType())
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/AdminApiTlsAuthTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/AdminApiTlsAuthTest.java
index 173e0557fb42..51e5a2ddfd3f 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/AdminApiTlsAuthTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/AdminApiTlsAuthTest.java
@@ -52,7 +52,8 @@ import org.apache.pulsar.common.policies.data.ClusterData;
 import org.apache.pulsar.common.policies.data.ResourceGroup;
 import org.apache.pulsar.common.policies.data.TenantInfoImpl;
 import org.apache.pulsar.common.tls.NoopHostnameVerifier;
-import org.apache.pulsar.common.util.SecurityUtility;
+import org.apache.pulsar.common.util.tls.JdkSslContexts;
+import org.apache.pulsar.common.util.tls.PemReader;
 import org.glassfish.jersey.client.ClientConfig;
 import org.glassfish.jersey.client.ClientProperties;
 import org.glassfish.jersey.jackson.JacksonFeature;
@@ -112,12 +113,12 @@ public class AdminApiTlsAuthTest extends MockedPulsarServiceBaseTest {
         ClientBuilder clientBuilder = ClientBuilder.newBuilder().withConfig(httpConfig)
             .register(JacksonConfigurator.class).register(JacksonFeature.class);
 
-        X509Certificate trustCertificates[] = SecurityUtility.loadCertificatesFromPemFile(
+        X509Certificate trustCertificates[] = PemReader.loadCertificatesFromPemFile(
                 CA_CERT_FILE_PATH);
-        SSLContext sslCtx = SecurityUtility.createSslContext(
+        SSLContext sslCtx = JdkSslContexts.createSslContext(
                 false, trustCertificates,
-                SecurityUtility.loadCertificatesFromPemFile(getTlsFileForClient(user + ".cert")),
-                SecurityUtility.loadPrivateKeyFromPemFile(getTlsFileForClient(user + ".key-pk8")));
+                PemReader.loadCertificatesFromPemFile(getTlsFileForClient(user + ".cert")),
+                PemReader.loadPrivateKeyFromPemFile(getTlsFileForClient(user + ".key-pk8")));
         clientBuilder.sslContext(sslCtx).hostnameVerifier(NoopHostnameVerifier.INSTANCE);
         Client client = clientBuilder.build();
 
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/BrokerAdminClientTlsFactoryTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/BrokerAdminClientTlsFactoryTest.java
new file mode 100644
index 000000000000..74746f77713e
--- /dev/null
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/BrokerAdminClientTlsFactoryTest.java
@@ -0,0 +1,87 @@
+/*
+ * 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.pulsar.broker.admin;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import java.util.Optional;
+import java.util.Set;
+import lombok.CustomLog;
+import org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest;
+import org.apache.pulsar.client.admin.internal.PulsarAdminImpl;
+import org.apache.pulsar.common.policies.data.ClusterData;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+/**
+ * PIP-478 stage 4b: the broker's own outbound admin client (the {@code AsyncHttpConnector} behind
+ * {@link org.apache.pulsar.broker.PulsarService#getAdminClient()}) on the new TLS SPI, opted in via
+ * {@code brokerClientTlsFactoryClassName=default}. A targeted variant of {@link BrokerAdminClientTlsAuthTest};
+ * that test exercises the legacy PIP-337 default (gate off) unmodified.
+ */
+@CustomLog
+@Test(groups = "broker-admin")
+public class BrokerAdminClientTlsFactoryTest extends MockedPulsarServiceBaseTest {
+
+    @BeforeMethod
+    @Override
+    public void setup() throws Exception {
+        conf.setWebServicePortTls(Optional.of(0));
+        conf.setBrokerServicePortTls(Optional.of(0));
+        conf.setTlsCertificateFilePath(BROKER_CERT_FILE_PATH);
+        conf.setTlsKeyFilePath(BROKER_KEY_FILE_PATH);
+        conf.setTlsTrustCertsFilePath(CA_CERT_FILE_PATH);
+        conf.setAuthenticationEnabled(true);
+        // The broker's own admin client authenticates with the broker cert (CN=broker-localhost-SAN).
+        conf.setSuperUserRoles(Set.of("broker-localhost-SAN"));
+        conf.setAuthenticationProviders(
+                Set.of("org.apache.pulsar.broker.authentication.AuthenticationProviderTls"));
+        conf.setAuthorizationEnabled(true);
+        conf.setBrokerClientTlsEnabled(true);
+        conf.setTlsAllowInsecureConnection(true);
+        // Opt in: route the broker's own outbound clients — including the admin client — onto
+        // the PIP-478 TLS SPI. The base MockedPulsarServiceBaseTest wires the broker-client AuthenticationTls
+        // material (broker cert/key + CA trust) automatically for the web-TLS + AuthenticationProviderTls case.
+        conf.setBrokerClientTlsFactoryClassName("default");
+        super.internalSetup();
+    }
+
+    @AfterMethod(alwaysRun = true)
+    @Override
+    public void cleanup() throws Exception {
+        super.internalCleanup();
+    }
+
+    @Test
+    public void brokerAdminClientRidesNewTlsPathAndWorksOverTls() throws Exception {
+        // The broker's own admin client targets its https web service; the gate routes its AsyncHttpConnector
+        // onto the new SPI.
+        PulsarAdminImpl admin = (PulsarAdminImpl) pulsar.getAdminClient();
+        assertThat(admin.getServiceUrl()).startsWith("https://");
+        assertThat(admin.getAsyncHttpConnector().getTlsFactory())
+                .as("the broker admin client should ride the PIP-478 TLS SPI when "
+                        + "brokerClientTlsFactoryClassName is set")
+                .isNotNull();
+        // Create then read a cluster over TLS: a full write+read admin round-trip driven by the new-SPI
+        // handshake (a TLS failure on either call would throw, so completion proves the factory is wired).
+        admin.clusters().createCluster(configClusterName,
+                ClusterData.builder().serviceUrl(brokerUrl.toString()).build());
+        assertThat(admin.clusters().getClusters()).contains(configClusterName);
+    }
+}
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/BrokerServiceTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/BrokerServiceTest.java
index 0d21ae73617a..91787645f1fa 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/BrokerServiceTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/BrokerServiceTest.java
@@ -21,6 +21,7 @@ package org.apache.pulsar.broker.service;
 import static org.apache.pulsar.broker.loadbalance.extensions.channel.ServiceUnitStateTableViewImpl.TOPIC;
 import static org.apache.pulsar.common.naming.SystemTopicNames.TRANSACTION_COORDINATOR_ASSIGN;
 import static org.apache.pulsar.common.naming.SystemTopicNames.TRANSACTION_COORDINATOR_LOG;
+import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.any;
 import static org.mockito.Mockito.doNothing;
@@ -760,7 +761,11 @@ public class BrokerServiceTest extends BrokerTestBase {
 
             fail("should fail");
         } catch (Exception e) {
-            assertTrue(e.getMessage().contains("unable to find valid certification path to requested target"));
+            // The PKIX failure is reported by the JDK trust manager the TLS engine delegates to, so it always
+            // appears in the cause chain. Which engine surfaces it, and therefore what the top-level message
+            // says, depends on the configured TLS provider: the JDK engine says "General SSLEngine problem"
+            // and the OpenSSL engine says "General OpenSslEngine problem". Assert on the chain, not the tip.
+            assertThat(e).hasStackTraceContaining("unable to find valid certification path to requested target");
         } finally {
             pulsarClient.close();
         }
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/OneWayReplicatorDeduplicationTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/OneWayReplicatorDeduplicationTest.java
index 20b9f0e8ded5..854bedb9392b 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/OneWayReplicatorDeduplicationTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/OneWayReplicatorDeduplicationTest.java
@@ -321,12 +321,10 @@ public class OneWayReplicatorDeduplicationTest extends OneWayReplicatorTestBase
             (conf, eventLoopGroup) -> new ClientCnx(InstrumentProvider.NOOP, conf, eventLoopGroup) {
 
                 @Override
-                protected ByteBuf newConnectCommand() throws Exception {
+                protected ByteBuf buildConnectCommand(AuthData authData) throws Exception {
                     if (supportsReplDedupByLidAndEid) {
-                        return super.newConnectCommand();
+                        return super.buildConnectCommand(authData);
                     }
-                    authenticationDataProvider = authentication.getAuthData(remoteHostName);
-                    AuthData authData = authenticationDataProvider.authenticate(AuthData.INIT_AUTH_DATA);
                     BaseCommand cmd = Commands.newConnectWithoutSerialize(authentication.getAuthMethodName(), authData,
                             this.protocolVersion, clientVersion, proxyToTargetBrokerAddress,
                             null, null, null, null, null);
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/ReplicatorTlsFactoryTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/ReplicatorTlsFactoryTest.java
new file mode 100644
index 000000000000..dc8bf787c188
--- /dev/null
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/ReplicatorTlsFactoryTest.java
@@ -0,0 +1,195 @@
+/*
+ * 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.pulsar.broker.service;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertTrue;
+import java.util.List;
+import java.util.Optional;
+import lombok.Cleanup;
+import lombok.CustomLog;
+import org.apache.pulsar.broker.BrokerTestUtil;
+import org.apache.pulsar.client.admin.PulsarAdmin;
+import org.apache.pulsar.client.admin.internal.PulsarAdminImpl;
+import org.apache.pulsar.client.api.PulsarClient;
+import org.apache.pulsar.client.impl.PulsarClientImpl;
+import org.apache.pulsar.client.impl.conf.ClientConfigurationData;
+import org.apache.pulsar.common.naming.TopicName;
+import org.apache.pulsar.common.policies.data.ClusterData;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+/**
+ * PIP-478 stage 4a: the broker's own outbound clients (geo-replication, cluster-internal lookup) on the new
+ * TLS SPI ({@code BROKER_CLIENT}). A targeted variant of {@link ReplicatorTlsTest} that opts in via
+ * {@code brokerClientTlsFactoryClassName=default}; {@link ReplicatorTlsTest} itself exercises the legacy
+ * PIP-337 default (gate off) unmodified.
+ */
+@Test(groups = "broker-replication")
+@CustomLog
+public class ReplicatorTlsFactoryTest extends ReplicatorTestBase {
+
+    @Override
+    @BeforeClass(timeOut = 300000)
+    public void setup() throws Exception {
+        // The per-cluster replication clients already ride TLS from their ClusterData brokerClientTls* material
+        // (configured by ReplicatorTestBase); opting in via brokerClientTlsFactoryClassName routes them onto the
+        // PIP-478 TLS SPI. (Deliberately not setting the ServiceConfiguration brokerClientTlsEnabled flag: it is
+        // unrelated to this gate — the replication path keys off ClusterData — and toggling it perturbs the
+        // remote-admin harness independently of PIP-478.)
+        config1.setBrokerClientTlsFactoryClassName("default");
+        config2.setBrokerClientTlsFactoryClassName("default");
+        config3.setBrokerClientTlsFactoryClassName("default");
+        super.setup();
+    }
+
+    @Override
+    @AfterClass(alwaysRun = true, timeOut = 300000)
+    public void cleanup() throws Exception {
+        super.cleanup();
+    }
+
+    @Test
+    public void testReplicationClientOnNewTlsPath() throws Exception {
+        log.info("--- Starting ReplicatorTlsFactoryTest::testReplicationClientOnNewTlsPath ---");
+        for (BrokerService ns : List.of(ns1, ns2, ns3)) {
+            ns.getReplicationClient(cluster1, Optional.of(admin1.clusters().getCluster(cluster1)));
+            ns.getReplicationClient(cluster2, Optional.of(admin1.clusters().getCluster(cluster2)));
+            ns.getReplicationClient(cluster3, Optional.of(admin1.clusters().getCluster(cluster3)));
+
+            ns.getReplicationClients().forEach((cluster, client) -> {
+                ClientConfigurationData conf = ((PulsarClientImpl) client).getConfiguration();
+                assertTrue(conf.isUseTls());
+                // The PIP-478 TLS factory was adopted for the broker's own outbound client.
+                assertNotNull(conf.getTlsFactory(), "broker-client should ride the PIP-478 TLS SPI when "
+                        + "brokerClientTlsFactoryClassName is set");
+                // The broker-client material is still mapped onto the config fields (composed into the factory).
+                assertEquals(conf.getTlsTrustCertsFilePath(), caCertFilePath);
+                assertEquals(conf.getTlsKeyFilePath(), clientKeyFilePath);
+                assertEquals(conf.getTlsCertificateFilePath(), clientCertFilePath);
+            });
+        }
+    }
+
+    @Test
+    public void perClusterTlsFactorySelectsTheFactoryWhenTheBrokerLevelSettingIsUnset() throws Exception {
+        // PIP-478: a ClusterData entry may name its own PulsarTlsFactory, so a deployment can drive
+        // connections to one remote cluster through a different factory than the rest. Prove the cluster's
+        // value alone is enough — with the broker-level key blank, which is how it ships.
+        // The blank side of each case is what makes it discriminating: if precedence were inverted, the
+        // resolved name would come back empty rather than "default".
+        String brokerLevel = config1.getBrokerClientTlsFactoryClassName();
+        try {
+            config1.setBrokerClientTlsFactoryClassName("");
+            ClusterData clusterSelects = admin1.clusters().getCluster(cluster2).clone()
+                    .brokerClientTlsFactoryClassName("default")
+                    .build();
+            PulsarClient overrides = ns1.getReplicationClient(
+                    BrokerTestUtil.newUniqueName(cluster2 + "-per-cluster"), Optional.of(clusterSelects));
+            ClientConfigurationData overrideConf = ((PulsarClientImpl) overrides).getConfiguration();
+            assertEquals(overrideConf.getTlsFactoryClassName(), "default",
+                    "the cluster's brokerClientTlsFactoryClassName must select the factory on its own");
+            // The name reaching the config is not enough: PulsarService.maybeApplyBrokerClientTlsFactory has
+            // to act on it, or the selection is silently dropped and a custom BROKER_CLIENT-only factory is
+            // later resolved unwrapped — which answers CLIENT_DEFAULT with empty and fails the connection.
+            assertNotNull(overrideConf.getTlsFactory(),
+                    "the per-cluster selection must actually build (and purpose-wrap) the factory");
+
+            config1.setBrokerClientTlsFactoryClassName("default");
+            ClusterData clusterSilent = admin1.clusters().getCluster(cluster2).clone()
+                    .brokerClientTlsFactoryClassName("")
+                    .build();
+            PulsarClient inherits = ns1.getReplicationClient(
+                    BrokerTestUtil.newUniqueName(cluster2 + "-inherits"), Optional.of(clusterSilent));
+            assertEquals(((PulsarClientImpl) inherits).getConfiguration().getTlsFactoryClassName(), "default",
+                    "a cluster that names no factory must inherit the broker-level one rather than silently "
+                            + "reverting to the default factory");
+
+            // With both sides set, the cluster wins. Asserted on tlsFactoryConfig because it is an opaque
+            // string: two distinct values can be compared without either having to resolve to a real class.
+            String brokerConfig = config1.getBrokerClientTlsFactoryConfig();
+            try {
+                config1.setBrokerClientTlsFactoryConfig("scope=broker");
+                ClusterData bothSet = admin1.clusters().getCluster(cluster2).clone()
+                        .brokerClientTlsFactoryClassName("default")
+                        .brokerClientTlsFactoryConfig("scope=cluster")
+                        .build();
+                PulsarClient wins = ns1.getReplicationClient(
+                        BrokerTestUtil.newUniqueName(cluster2 + "-both-set"), Optional.of(bothSet));
+                assertEquals(((PulsarClientImpl) wins).getConfiguration().getTlsFactoryConfig(), "scope=cluster",
+                        "the cluster's setting must take precedence over the broker-level one");
+            } finally {
+                config1.setBrokerClientTlsFactoryConfig(brokerConfig);
+            }
+        } finally {
+            config1.setBrokerClientTlsFactoryClassName(brokerLevel);
+        }
+    }
+
+    @Test
+    public void perClusterTlsFactoryAlsoDrivesTheCrossClusterAdminClient() throws Exception {
+        // A cluster entry drives two outbound legs. getClusterPulsarAdmin() is the second one, and it went
+        // through a different apply method that read the broker-level key only, so the cluster's selection
+        // never reached the admin client.
+        String brokerLevel = config1.getBrokerClientTlsFactoryClassName();
+        try {
+            config1.setBrokerClientTlsFactoryClassName("");
+            ClusterData clusterSelects = admin1.clusters().getCluster(cluster2).clone()
+                    .brokerClientTlsFactoryClassName("default")
+                    .build();
+            PulsarAdmin clusterAdmin = ns1.getClusterPulsarAdmin(
+                    BrokerTestUtil.newUniqueName(cluster2 + "-admin"), Optional.of(clusterSelects));
+            ClientConfigurationData conf = ((PulsarAdminImpl) clusterAdmin).getClientConfigData();
+            assertEquals(conf.getTlsFactoryClassName(), "default",
+                    "the cluster's factory selection must reach the admin client configuration");
+            assertNotNull(conf.getTlsFactory(),
+                    "the per-cluster selection must build the admin client's factory too");
+        } finally {
+            config1.setBrokerClientTlsFactoryClassName(brokerLevel);
+        }
+    }
+
+    @Test
+    public void testReplicatesAcrossClustersOnNewTlsPath() throws Exception {
+        log.info("--- Starting ReplicatorTlsFactoryTest::testReplicatesAcrossClustersOnNewTlsPath ---");
+        // A real broker-to-broker TLS handshake on the new path: replication rides the BROKER_CLIENT
+        // connection, so producing on cluster1 and consuming the replicated messages on cluster2 / cluster3
+        // exercises the new-SPI TLS handshake end to end.
+        final TopicName dest = TopicName.get(
+                BrokerTestUtil.newUniqueName("persistent://pulsar/ns/tlsFactoryReplTopic"));
+
+        @Cleanup
+        MessageProducer producer1 = new MessageProducer(url1, dest);
+        @Cleanup
+        MessageConsumer consumer1 = new MessageConsumer(url1, dest);
+        @Cleanup
+        MessageConsumer consumer2 = new MessageConsumer(url2, dest);
+        @Cleanup
+        MessageConsumer consumer3 = new MessageConsumer(url3, dest);
+
+        // Local first (confirms the topic + starts the replicators to r2/r3 over the BROKER_CLIENT connection).
+        producer1.produce(2);
+        consumer1.receive(2);
+        // Replicated: broker-to-broker delivery over the new-SPI TLS connection.
+        consumer2.receive(2, 30);
+        consumer3.receive(2, 30);
+    }
+}
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/tls/BrokerClientPerClusterTlsFactoryTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/tls/BrokerClientPerClusterTlsFactoryTest.java
new file mode 100644
index 000000000000..0abd2280c3e6
--- /dev/null
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/tls/BrokerClientPerClusterTlsFactoryTest.java
@@ -0,0 +1,281 @@
+/*
+ * 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.pulsar.broker.tls;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import io.netty.handler.ssl.SslContext;
+import io.netty.handler.ssl.SslContextBuilder;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+import org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest;
+import org.apache.pulsar.client.impl.PulsarClientImpl;
+import org.apache.pulsar.common.policies.data.ClusterData;
+import org.apache.pulsar.tls.PulsarTlsFactory;
+import org.apache.pulsar.tls.TlsFactoryInitContext;
+import org.apache.pulsar.tls.TlsHandle;
+import org.apache.pulsar.tls.TlsPurpose;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+/**
+ * PIP-478: per-cluster broker-client TLS factory selection, exercised through the broker rather than through
+ * {@code ClientTlsFactorySupport} in isolation. Two things only a broker-level test can show:
+ *
+ * <ul>
+ *   <li><b>{@code wrapBrokerClientPurpose} actually runs.</b> A custom broker-client factory resolves under
+ *       {@code BROKER_CLIENT} by the PIP's stable-API contract, but the client transport asks for
+ *       {@code CLIENT_DEFAULT}. Without the wrapper a compliant custom factory returns {@code empty()} and the
+ *       connection fails. The recording factories below serve <em>only</em> {@code BROKER_CLIENT}, so they
+ *       would fail in exactly that way — the previous tests all used {@code "default"}, which never reaches
+ *       the wrapper.</li>
+ *   <li><b>The class name / config pair resolves atomically.</b> A cluster that names a factory uses its own
+ *       config even when blank, so factory A's parameters can never be handed to factory B; the broker-level
+ *       config applies only when the cluster names no factory. This is where inverted logic would hide.</li>
+ * </ul>
+ *
+ * <p>The resolved factory is asked for {@code CLIENT_DEFAULT} directly — the same request
+ * {@code PulsarChannelInitializer} makes when a connection is established — so no peer cluster has to exist.
+ */
+@Test(groups = "broker")
+public class BrokerClientPerClusterTlsFactoryTest extends MockedPulsarServiceBaseTest {
+
+    private static final String BROKER_LEVEL_CONFIG = "owner=broker-level";
+    private static final String CLUSTER_LEVEL_CONFIG = "owner=cluster-level";
+
+    @BeforeMethod
+    @Override
+    protected void setup() throws Exception {
+        BrokerLevelTlsFactory.reset();
+        ClusterLevelTlsFactory.reset();
+        conf.setBrokerClientTlsEnabled(true);
+        conf.setBrokerClientTlsFactoryClassName(BrokerLevelTlsFactory.class.getName());
+        conf.setBrokerClientTlsFactoryConfig(BROKER_LEVEL_CONFIG);
+        internalSetup();
+    }
+
+    @AfterMethod(alwaysRun = true)
+    @Override
+    protected void cleanup() throws Exception {
+        internalCleanup();
+        BrokerLevelTlsFactory.reset();
+        ClusterLevelTlsFactory.reset();
+    }
+
+    @Test
+    public void theTransportsClientDefaultRequestReachesTheFactoryAsBrokerClient() throws Exception {
+        PulsarTlsFactory factory = replicationClientTlsFactory(clusterNaming(null, null));
+
+        // Exactly what PulsarChannelInitializer asks for on the client transport.
+        Optional<TlsHandle<SslContext>> handle =
+                factory.createInstance(TlsPurpose.CLIENT_DEFAULT, SslContext.class).get(30, TimeUnit.SECONDS);
+
+        // Both this request and the client build's fail-fast probe go through the wrapper, so assert the
+        // invariant (every request arrives translated) rather than a request count.
+        assertThat(BrokerLevelTlsFactory.REQUESTED_PURPOSES)
+                .as("the wrapper must translate the transport's CLIENT_DEFAULT to BROKER_CLIENT before "
+                        + "delegating; a compliant BROKER_CLIENT-only custom factory sees only BROKER_CLIENT")
+                .isNotEmpty()
+                .containsOnly(TlsPurpose.BROKER_CLIENT);
+        assertThat(handle).as("so a BROKER_CLIENT-only factory can serve the transport's request").isPresent();
+    }
+
+    @Test
+    public void aClusterNamingItsOwnFactoryWinsOverTheBrokerLevelOne() throws Exception {
+        PulsarTlsFactory factory = replicationClientTlsFactory(
+                clusterNaming(ClusterLevelTlsFactory.class.getName(), CLUSTER_LEVEL_CONFIG));
+
+        assertThat(factory.createInstance(TlsPurpose.CLIENT_DEFAULT, SslContext.class).get(30, TimeUnit.SECONDS))
+                .isPresent();
+        assertThat(ClusterLevelTlsFactory.instantiated)
+                .as("the cluster's own factory is the one instantiated").isTrue();
+        assertThat(BrokerLevelTlsFactory.instantiated)
+                .as("the broker-level factory must not be used by a cluster that names its own").isFalse();
+        assertThat(ClusterLevelTlsFactory.initParams)
+                .as("and it receives its own cluster-level config")
+                .containsExactly(Map.entry("owner", "cluster-level"));
+    }
+
+    @Test
+    public void aClusterNamingAFactoryWithBlankConfigDoesNotInheritTheBrokerLevelConfig() throws Exception {
+        PulsarTlsFactory factory = replicationClientTlsFactory(
+                clusterNaming(ClusterLevelTlsFactory.class.getName(), ""));
+
+        assertThat(factory.createInstance(TlsPurpose.CLIENT_DEFAULT, SslContext.class).get(30, TimeUnit.SECONDS))
+                .isPresent();
+        assertThat(ClusterLevelTlsFactory.initParams)
+                .as("the config follows the class name: naming a factory with a blank config means blank, never "
+                        + "the broker-level config — otherwise factory A's parameters reach factory B")
+                .isEmpty();
+    }
+
+    @Test
+    public void aClusterNamingNoFactoryInheritsBothBrokerLevelValues() throws Exception {
+        PulsarTlsFactory factory = replicationClientTlsFactory(clusterNaming(null, null));
+
+        assertThat(factory.createInstance(TlsPurpose.CLIENT_DEFAULT, SslContext.class).get(30, TimeUnit.SECONDS))
+                .isPresent();
+        assertThat(BrokerLevelTlsFactory.instantiated)
+                .as("the broker-level factory applies when the cluster names none").isTrue();
+        assertThat(ClusterLevelTlsFactory.instantiated).isFalse();
+        assertThat(BrokerLevelTlsFactory.initParams)
+                .as("and the broker-level config applies with it")
+                .containsExactly(Map.entry("owner", "broker-level"));
+    }
+
+    private ClusterData clusterNaming(String factoryClassName, String factoryConfig) {
+        return ClusterData.builder()
+                .serviceUrl("http://peer-cluster:8080")
+                .brokerServiceUrl("pulsar://peer-cluster:6650")
+                .serviceUrlTls("https://peer-cluster:8443")
+                .brokerServiceUrlTls("pulsar+ssl://peer-cluster:6651")
+                .brokerClientTlsEnabled(true)
+                .brokerClientTlsFactoryClassName(factoryClassName)
+                .brokerClientTlsFactoryConfig(factoryConfig)
+                .build();
+    }
+
+    /** The TLS factory the replication (binary) leg to a peer cluster ends up using. */
+    private PulsarTlsFactory replicationClientTlsFactory(ClusterData clusterData) {
+        PulsarClientImpl client = (PulsarClientImpl) pulsar.getBrokerService()
+                .getReplicationClient("peer-cluster", Optional.of(clusterData));
+        PulsarTlsFactory factory = client.getConfiguration().getTlsFactory();
+        assertThat(factory).as("an opted-in broker-client leg resolves a TLS factory").isNotNull();
+        return factory;
+    }
+
+    /**
+     * A custom broker-client factory that records the purposes it is asked for and the init params it was
+     * given. It serves <em>only</em> {@link TlsPurpose#BROKER_CLIENT} — the PIP-478 contract for a custom
+     * broker-client factory — so a missing purpose translation shows up as an empty result rather than as a
+     * silently passing test.
+     */
+    abstract static class RecordingTlsFactory implements PulsarTlsFactory {
+
+        abstract void recordPurpose(TlsPurpose purpose);
+
+        abstract void recordParams(Map<String, String> params);
+
+        @Override
+        public CompletableFuture<Void> initialize(TlsFactoryInitContext context) {
+            recordParams(context.params());
+            return CompletableFuture.completedFuture(null);
+        }
+
+        @Override
+        public <T> CompletableFuture<Optional<TlsHandle<T>>> createInstance(TlsPurpose purpose,
+                Class<T> instanceClass) {
+            recordPurpose(purpose);
+            if (!TlsPurpose.BROKER_CLIENT.equals(purpose) || instanceClass != SslContext.class) {
+                return CompletableFuture.completedFuture(Optional.empty());
+            }
+            try {
+                SslContext context = SslContextBuilder.forClient().build();
+                TlsHandle<T> handle = new TlsHandle<>() {
+                    @Override
+                    @SuppressWarnings("unchecked")
+                    public T get() {
+                        return (T) context;
+                    }
+
+                    @Override
+                    public void dispose() {
+                    }
+                };
+                return CompletableFuture.completedFuture(Optional.of(handle));
+            } catch (Exception e) {
+                return CompletableFuture.failedFuture(e);
+            }
+        }
+
+        @Override
+        public <T> CompletableFuture<Optional<TlsHandle<T>>> createInstance(TlsPurpose purpose,
+                Class<T> instanceClass, Consumer<T> onLoadOrReload) {
+            return createInstance(purpose, instanceClass).thenApply(opt -> {
+                opt.ifPresent(handle -> onLoadOrReload.accept(handle.get()));
+                return opt;
+            });
+        }
+
+        @Override
+        public void close() {
+        }
+    }
+
+    /** Selected by {@code ServiceConfiguration.brokerClientTlsFactoryClassName}. */
+    public static class BrokerLevelTlsFactory extends RecordingTlsFactory {
+
+        static final List<TlsPurpose> REQUESTED_PURPOSES = new CopyOnWriteArrayList<>();
+        static volatile Map<String, String> initParams = Map.of();
+        static volatile boolean instantiated;
+
+        static void reset() {
+            REQUESTED_PURPOSES.clear();
+            initParams = Map.of();
+            instantiated = false;
+        }
+
+        public BrokerLevelTlsFactory() {
+            instantiated = true;
+        }
+
+        @Override
+        void recordPurpose(TlsPurpose purpose) {
+            REQUESTED_PURPOSES.add(purpose);
+        }
+
+        @Override
+        void recordParams(Map<String, String> params) {
+            initParams = Map.copyOf(params);
+        }
+    }
+
+    /** Selected by {@code ClusterData.brokerClientTlsFactoryClassName}, so "which class won" is observable. */
+    public static class ClusterLevelTlsFactory extends RecordingTlsFactory {
+
+        static final List<TlsPurpose> REQUESTED_PURPOSES = new CopyOnWriteArrayList<>();
+        static volatile Map<String, String> initParams = Map.of();
+        static volatile boolean instantiated;
+
+        static void reset() {
+            REQUESTED_PURPOSES.clear();
+            initParams = Map.of();
+            instantiated = false;
+        }
+
+        public ClusterLevelTlsFactory() {
+            instantiated = true;
+        }
+
+        @Override
+        void recordPurpose(TlsPurpose purpose) {
+            REQUESTED_PURPOSES.add(purpose);
+        }
+
+        @Override
+        void recordParams(Map<String, String> params) {
+            initParams = Map.copyOf(params);
+        }
+    }
+}
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/tls/CountingWebTlsFactory.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/tls/CountingWebTlsFactory.java
new file mode 100644
index 000000000000..cc3aba86c24a
--- /dev/null
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/tls/CountingWebTlsFactory.java
@@ -0,0 +1,107 @@
+/*
+ * 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.pulsar.broker.tls;
+
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Consumer;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.pulsar.common.tls.impl.FileBasedTlsFactory;
+import org.apache.pulsar.common.tls.impl.FileBasedTlsFactorySettings;
+import org.apache.pulsar.tls.PulsarTlsFactory;
+import org.apache.pulsar.tls.TlsEndpoint;
+import org.apache.pulsar.tls.TlsFactoryInitContext;
+import org.apache.pulsar.tls.TlsHandle;
+import org.apache.pulsar.tls.TlsPolicy;
+import org.apache.pulsar.tls.TlsPurpose;
+
+/**
+ * A PIP-478 test factory selected via {@code tlsFactoryClassName} that counts every {@code createInstance}
+ * call so a test can prove the new TLS SPI was actually exercised for the WEB (HTTPS) purpose — the Jetty web
+ * path requests the JDK {@code javax.net.ssl.SSLContext} and {@code SSLParameters}, so unlike
+ * {@code SslContextOnlyTlsFactory} (which counts only refused Netty {@code SslContext} requests) this counts
+ * the JDK-context path. Material loading is delegated to a real {@link FileBasedTlsFactory} built from the
+ * {@code trust}/{@code cert}/{@code key}/{@code insecure} factory-config params, so the served context is a
+ * genuine, usable WEB context.
+ *
+ * <p>Reflectively instantiable via the public no-arg constructor.
+ */
+public class CountingWebTlsFactory implements PulsarTlsFactory {
+
+    private static final AtomicInteger CREATE_INSTANCE_CALLS = new AtomicInteger();
+
+    private volatile FileBasedTlsFactory delegate;
+
+    public CountingWebTlsFactory() {
+    }
+
+    /** Total number of {@code createInstance} calls served (across all instances). */
+    public static int createInstanceCount() {
+        return CREATE_INSTANCE_CALLS.get();
+    }
+
+    public static void reset() {
+        CREATE_INSTANCE_CALLS.set(0);
+    }
+
+    @Override
+    public CompletableFuture<Void> initialize(TlsFactoryInitContext context) {
+        Map<String, String> params = context.params();
+        TlsPolicy policy = TlsPolicy.builder()
+                .format(TlsPolicy.Format.PEM)
+                .trustCertsFilePath(StringUtils.trimToNull(params.get("trust")))
+                .certificateFilePath(StringUtils.trimToNull(params.get("cert")))
+                .keyFilePath(StringUtils.trimToNull(params.get("key")))
+                .allowInsecureConnection(Boolean.parseBoolean(params.getOrDefault("insecure", "false")))
+                .enableHostnameVerification(false)
+                .build();
+        this.delegate = new FileBasedTlsFactory(Map.of(TlsPurpose.WEB, policy),
+                FileBasedTlsFactorySettings.builder().build());
+        return delegate.initialize(context);
+    }
+
+    @Override
+    public <T> CompletableFuture<Optional<TlsHandle<T>>> createInstance(TlsPurpose purpose, Class<T> instanceClass) {
+        CREATE_INSTANCE_CALLS.incrementAndGet();
+        return delegate.createInstance(purpose, instanceClass);
+    }
+
+    @Override
+    public <T> CompletableFuture<Optional<TlsHandle<T>>> createInstance(
+            TlsPurpose purpose, TlsEndpoint endpoint, Class<T> instanceClass) {
+        CREATE_INSTANCE_CALLS.incrementAndGet();
+        return delegate.createInstance(purpose, endpoint, instanceClass);
+    }
+
+    @Override
+    public <T> CompletableFuture<Optional<TlsHandle<T>>> createInstance(
+            TlsPurpose purpose, Class<T> instanceClass, Consumer<T> onLoadOrReload) {
+        CREATE_INSTANCE_CALLS.incrementAndGet();
+        return delegate.createInstance(purpose, instanceClass, onLoadOrReload);
+    }
+
+    @Override
+    public void close() {
+        if (delegate != null) {
+            delegate.close();
+        }
+    }
+}
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/web/WebServiceTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/web/WebServiceTest.java
index adf9132f9a94..1aa82ab5464a 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/web/WebServiceTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/web/WebServiceTest.java
@@ -73,7 +73,7 @@ import org.apache.pulsar.common.policies.data.ClusterData;
 import org.apache.pulsar.common.policies.data.ClusterDataImpl;
 import org.apache.pulsar.common.policies.data.TenantInfo;
 import org.apache.pulsar.common.util.ObjectMapperFactory;
-import org.apache.pulsar.common.util.SecurityUtility;
+import org.apache.pulsar.common.util.tls.PemReader;
 import org.apache.pulsar.utils.ResourceUtils;
 import org.asynchttpclient.AsyncHttpClient;
 import org.asynchttpclient.BoundRequestBuilder;
@@ -471,8 +471,8 @@ public class WebServiceTest {
             if (useTls) {
                 KeyManager[] keyManagers = null;
                 if (useAuth) {
-                    Certificate[] tlsCert = SecurityUtility.loadCertificatesFromPemFile(CLIENT_CERT_FILE_PATH);
-                    PrivateKey tlsKey = SecurityUtility.loadPrivateKeyFromPemFile(CLIENT_KEY_FILE_PATH);
+                    Certificate[] tlsCert = PemReader.loadCertificatesFromPemFile(CLIENT_CERT_FILE_PATH);
+                    PrivateKey tlsKey = PemReader.loadPrivateKeyFromPemFile(CLIENT_KEY_FILE_PATH);
 
                     KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType());
                     ks.load(null, null);
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/client/api/AuthenticationTlsHostnameVerificationTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/AuthenticationTlsHostnameVerificationTest.java
index c91c2fec9763..82db1c79c17e 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/client/api/AuthenticationTlsHostnameVerificationTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/AuthenticationTlsHostnameVerificationTest.java
@@ -18,7 +18,6 @@
  */
 package org.apache.pulsar.client.api;
 
-import java.lang.reflect.Method;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Map;
@@ -29,8 +28,6 @@ import lombok.CustomLog;
 import org.apache.pulsar.broker.authentication.AuthenticationProviderBasic;
 import org.apache.pulsar.broker.authentication.AuthenticationProviderTls;
 import org.apache.pulsar.client.impl.auth.AuthenticationTls;
-import org.apache.pulsar.common.tls.PublicSuffixMatcher;
-import org.apache.pulsar.common.tls.TlsHostnameVerifier;
 import org.assertj.core.util.Sets;
 import org.testng.Assert;
 import org.testng.annotations.AfterMethod;
@@ -113,9 +110,7 @@ public class AuthenticationTlsHostnameVerificationTest extends ProducerConsumerB
     @AfterMethod(alwaysRun = true)
     @Override
     protected void cleanup() throws Exception {
-        if (!methodName.equals("testDefaultHostVerifier")) {
-            super.internalCleanup();
-        }
+        super.internalCleanup();
     }
 
     @DataProvider(name = "hostnameVerification")
@@ -128,8 +123,10 @@ public class AuthenticationTlsHostnameVerificationTest extends ProducerConsumerB
      *
      * <pre>
      * 1. Client tries to connect to broker with hostname="localhost"
-     * 2. Broker sends x509 certificates with CN = "pulsar"
-     * 3. Client verifies the host-name and closes the connection and fails consumer creation
+     * 2. Broker sends an x509 certificate whose SubjectAltName does not match "localhost"
+     *    (in fact it has no SAN at all, only CN=broker.pulsar.apache.org)
+     * 3. Client performs SAN-based (RFC 2818) host-name verification, closes the connection
+     *    and fails consumer creation
      * </pre>
      *
      * @throws Exception
@@ -142,7 +139,13 @@ public class AuthenticationTlsHostnameVerificationTest extends ProducerConsumerB
 
         this.hostnameVerificationEnabled = hostnameVerificationEnabled;
         clientTrustCertFilePath = TLS_MIM_TRUST_CERT_FILE_PATH;
-        // setup broker cert which has CN = "pulsar" different than broker's hostname="localhost"
+        // setup broker cert which has no SAN matching the broker's hostname="localhost"
+        // (CN=broker.pulsar.apache.org, no subjectAltName): CN-based matching is no longer supported (PIP-478).
+        // The broker itself uses this deliberately-mismatched cert, so its own internal client (e.g. the
+        // system-topic reader for topic policies) cannot verify "localhost" against it; disable hostname
+        // verification for the broker's outbound client here. This test exercises the end-user client's
+        // hostname verification only (toggled via enableTlsHostnameVerification below).
+        conf.setTlsHostnameVerificationEnabled(false);
         conf.setBrokerServicePortTls(Optional.of(0));
         conf.setWebServicePortTls(Optional.of(0));
         conf.setAuthenticationProviders(Sets.newTreeSet(AuthenticationProviderTls.class.getName()));
@@ -175,8 +178,8 @@ public class AuthenticationTlsHostnameVerificationTest extends ProducerConsumerB
      *
      * <pre>
      * 1. Client tries to connect to broker with hostname="localhost"
-     * 2. Broker sends x509 certificates with CN = "localhost"
-     * 3. Client verifies the host-name and continues
+     * 2. Broker sends an x509 certificate whose SubjectAltName includes DNS:localhost
+     * 3. Client performs SAN-based (RFC 2818) host-name verification and continues
      * </pre>
      *
      * @throws Exception
@@ -185,7 +188,7 @@ public class AuthenticationTlsHostnameVerificationTest extends ProducerConsumerB
     public void testTlsSyncProducerAndConsumerCorrectBrokerHost() throws Exception {
         log.info().attr("starting", methodName).log("-- Starting test");
         cleanup();
-        // setup broker cert which has CN = "localhost"
+        // setup broker cert whose subjectAltName includes DNS:localhost
         conf.setBrokerServicePortTls(Optional.of(0));
         conf.setWebServicePortTls(Optional.of(0));
         conf.setAuthenticationProviders(Sets.newTreeSet(AuthenticationProviderTls.class.getName()));
@@ -221,24 +224,4 @@ public class AuthenticationTlsHostnameVerificationTest extends ProducerConsumerB
         log.info().attr("exiting", methodName).log("-- Exiting test");
     }
 
-    /**
-     * This test verifies {@link TlsHostnameVerifier} behavior and gives fair idea about host matching result.
-     *
-     * @throws Exception
-     */
-    @Test
-    public void testDefaultHostVerifier() throws Exception {
-        log.info().attr("starting", methodName).log("-- Starting test");
-        Method matchIdentityStrict = TlsHostnameVerifier.class.getDeclaredMethod("matchIdentityStrict",
-                String.class, String.class, PublicSuffixMatcher.class);
-        matchIdentityStrict.setAccessible(true);
-        Assert.assertTrue((boolean) matchIdentityStrict.invoke(null, "pulsar", "pulsar", null));
-        Assert.assertFalse((boolean) matchIdentityStrict.invoke(null, "pulsar.com", "pulsar", null));
-        Assert.assertTrue((boolean) matchIdentityStrict.invoke(null, "pulsar-broker1.com", "pulsar*.com", null));
-        // unmatched remainder: "1-broker." should not contain "."
-        Assert.assertFalse((boolean) matchIdentityStrict.invoke(null, "pulsar-broker1.com", "pulsar*com", null));
-        Assert.assertFalse((boolean) matchIdentityStrict.invoke(null, "pulsar.com", "*", null));
-        log.info().attr("exiting", methodName).log("-- Exiting test");
-    }
-
 }
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/client/api/BrokerServiceLookupTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/BrokerServiceLookupTest.java
index 997691921edd..6a07d33e6001 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/client/api/BrokerServiceLookupTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/BrokerServiceLookupTest.java
@@ -95,7 +95,7 @@ import org.apache.pulsar.common.policies.data.ClusterData;
 import org.apache.pulsar.common.policies.data.PoliciesUtil;
 import org.apache.pulsar.common.policies.data.TenantInfoImpl;
 import org.apache.pulsar.common.util.ObjectMapperFactory;
-import org.apache.pulsar.common.util.SecurityUtility;
+import org.apache.pulsar.common.util.tls.JdkSslContexts;
 import org.apache.pulsar.policies.data.loadbalancer.LocalBrokerData;
 import org.apache.pulsar.policies.data.loadbalancer.NamespaceBundleStats;
 import org.apache.zookeeper.KeeperException;
@@ -620,7 +620,7 @@ public class BrokerServiceLookupTest extends ProducerConsumerBase implements ITe
         final String lookupResourceUrl = "/lookup/v2/topic/persistent/my-property/my-ns/my-topic1";
 
         // set client cert_key file
-        SSLContext sslCtx = SecurityUtility.createSslContext(false, CA_CERT_FILE_PATH,
+        SSLContext sslCtx = JdkSslContexts.createSslContext(false, CA_CERT_FILE_PATH,
                 getTlsFileForClient("admin.cert"), getTlsFileForClient("admin.key-pk8"), "");
         HttpsURLConnection.setDefaultSSLSocketFactory(sslCtx.getSocketFactory());
 
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/client/api/ProxyProtocolTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/ProxyProtocolTest.java
index edb58abfdf22..ec90d3827821 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/client/api/ProxyProtocolTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/ProxyProtocolTest.java
@@ -44,9 +44,13 @@ public class ProxyProtocolTest extends TlsProducerConsumerBase {
     @Test
     public void testSniProxyProtocol() throws Exception {
 
-        // Client should try to connect to proxy and pass broker-url as SNI header
+        // Client should try to connect to proxy and pass broker-url as SNI header.
+        // The broker service URL host must be present in the broker certificate's SAN so that
+        // (now default-on) hostname verification passes: broker.cert.pem carries
+        // DNS:unresolvable-broker-address. The host is never resolved because SNI routing sends
+        // the connection to the proxy; it only needs to match the presented certificate.
         String proxyUrl = pulsar.getBrokerServiceUrlTls();
-        String brokerServiceUrl = "pulsar+ssl://unresolvable-address:6651";
+        String brokerServiceUrl = "pulsar+ssl://unresolvable-broker-address:6651";
         String topicName = "persistent://my-property/my-ns/my-topic1";
 
         ClientBuilder clientBuilder = PulsarClient.builder().serviceUrl(brokerServiceUrl)
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/client/api/TlsFactoryProducerConsumerTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/TlsFactoryProducerConsumerTest.java
new file mode 100644
index 000000000000..7a59002ff874
--- /dev/null
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/TlsFactoryProducerConsumerTest.java
@@ -0,0 +1,96 @@
+/*
+ * 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.pulsar.client.api;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import java.util.concurrent.TimeUnit;
+import lombok.Cleanup;
+import org.testng.annotations.Test;
+
+/**
+ * PIP-478 stage 2b: verifies the broker serves TLS end-to-end over the new {@code PulsarTlsFactory} path
+ * (selected by {@code tlsFactoryClassName=default}, which wires the built-in {@code DefaultBrokerTlsFactory})
+ * for both the binary listener (purpose BROKER) and the web server (purpose WEB), including mTLS client-auth
+ * in REQUIRE mode. The legacy PIP-337 path is exercised by {@link TlsProducerConsumerTest}.
+ */
+@Test(groups = "broker-api")
+public class TlsFactoryProducerConsumerTest extends TlsProducerConsumerBase {
+
+    @Override
+    protected void internalSetUpForBroker() {
+        super.internalSetUpForBroker();
+        // Select the new PIP-478 TLS SPI (built-in DefaultBrokerTlsFactory) for BROKER/PROXY/WEB purposes.
+        conf.setTlsFactoryClassName("default");
+    }
+
+    @Test(timeOut = 60000)
+    public void testTlsFactoryProduceConsumeOverBinaryAndHttps() throws Exception {
+        // Admin over HTTPS exercises the web server on the new WEB-purpose path.
+        internalSetUpForNamespace();
+
+        // Producer/consumer over binary TLS with mTLS client-auth exercises the BROKER-purpose path.
+        internalSetUpForClient(true, pulsar.getBrokerServiceUrlTls());
+        final String topic = "persistent://my-property/my-ns/tls-factory-topic";
+
+        @Cleanup
+        Consumer<byte[]> consumer = pulsarClient.newConsumer().topic(topic)
+                .subscriptionName("tls-factory-sub").subscribe();
+
+        @Cleanup
+        Producer<byte[]> producer = pulsarClient.newProducer().topic(topic).create();
+
+        final int numMessages = 10;
+        for (int i = 0; i < numMessages; i++) {
+            producer.send(("msg-" + i).getBytes());
+        }
+
+        for (int i = 0; i < numMessages; i++) {
+            Message<byte[]> msg = consumer.receive(5, TimeUnit.SECONDS);
+            assertThat(msg).isNotNull();
+            assertThat(new String(msg.getData())).isEqualTo("msg-" + i);
+            consumer.acknowledge(msg);
+        }
+    }
+
+    @Test(timeOut = 60000)
+    public void testTlsFactoryBinaryProtocolRequiresClientCert() throws Exception {
+        internalSetUpForNamespace();
+        final String topic = "persistent://my-property/my-ns/tls-factory-noauth-topic";
+
+        // Without client certs, the REQUIRE-mode broker must fail the TLS handshake.
+        internalSetUpForClient(false, pulsar.getBrokerServiceUrlTls());
+        assertThat(catchSubscribeFailure(topic)).isTrue();
+
+        // With client certs, the connection succeeds.
+        internalSetUpForClient(true, pulsar.getBrokerServiceUrlTls());
+        @Cleanup
+        Consumer<byte[]> consumer = pulsarClient.newConsumer().topic(topic)
+                .subscriptionName("tls-factory-sub").subscribe();
+        assertThat(consumer.isConnected()).isTrue();
+    }
+
+    private boolean catchSubscribeFailure(String topic) {
+        try (Consumer<byte[]> consumer = pulsarClient.newConsumer().topic(topic)
+                .subscriptionName("tls-factory-sub").subscriptionType(SubscriptionType.Exclusive).subscribe()) {
+            return false;
+        } catch (Exception ex) {
+            return true;
+        }
+    }
+}
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/client/api/v5/V5TlsProducerConsumerTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/v5/V5TlsProducerConsumerTest.java
new file mode 100644
index 000000000000..9842282a24ba
--- /dev/null
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/v5/V5TlsProducerConsumerTest.java
@@ -0,0 +1,115 @@
+/*
+ * 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.pulsar.client.api.v5;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import java.time.Duration;
+import lombok.Cleanup;
+import org.apache.pulsar.client.api.TlsProducerConsumerBase;
+import org.apache.pulsar.client.api.v5.auth.AuthenticationFactory;
+import org.apache.pulsar.client.api.v5.schema.Schema;
+import org.apache.pulsar.tls.TlsPolicy;
+import org.testng.annotations.Test;
+
+/**
+ * End-to-end mTLS produce/consume over the v5 client's new PIP-478 TLS path (stage 3b): a v5
+ * {@link PulsarClient} configured with {@code tlsPolicy(...)} against a TLS broker that requires a trusted
+ * client certificate. The binary transport builds its per-connection {@code SslContext} through the
+ * client-side {@code FileBasedTlsFactory} — the only client TLS path since the PIP-337 removal (stage 4c).
+ */
+@Test(groups = "broker-api")
+public class V5TlsProducerConsumerTest extends TlsProducerConsumerBase {
+
+    private static final String TOPIC = "persistent://my-property/my-ns/v5-tls-topic";
+
+    /** mTLS material supplied directly through {@link PulsarClientBuilder#tlsPolicy(TlsPolicy)}. */
+    @Test
+    public void testV5MtlsViaTlsPolicy() throws Exception {
+        internalSetUpForNamespace();
+
+        TlsPolicy policy = TlsPolicy.builder()
+                .format(TlsPolicy.Format.PEM)
+                .trustCertsFilePath(CA_CERT_FILE_PATH)
+                .certificateFilePath(getTlsFileForClient("admin.cert"))
+                .keyFilePath(getTlsFileForClient("admin.key-pk8"))
+                // The shared test broker cert is not guaranteed to carry a SAN for the advertised host, so
+                // (as the v4 TLS suites do) hostname verification is left off; the new path bakes trust and
+                // the client certificate into the factory-built context.
+                .enableHostnameVerification(false)
+                .build();
+
+        @Cleanup
+        PulsarClient client = PulsarClient.builder()
+                .serviceUrl(pulsar.getBrokerServiceUrlTls())
+                .tlsPolicy(policy)
+                .build();
+
+        assertProduceConsume(client, TOPIC + "-policy", "v5-mtls-via-policy");
+    }
+
+    /**
+     * mTLS material (trust + client certificate/key) supplied through {@code tlsPolicy(...)} and the auth
+     * method advertised through the built-in no-arg {@link AuthenticationFactory#tls()} marker plugin,
+     * which carries no material itself — the broker authenticates from the certificate presented during the
+     * TLS handshake, sourced from the client TLS factory.
+     */
+    @Test
+    public void testV5MtlsViaTlsPolicyWithTlsAuthMarker() throws Exception {
+        internalSetUpForNamespace();
+
+        TlsPolicy policy = TlsPolicy.builder()
+                .format(TlsPolicy.Format.PEM)
+                .trustCertsFilePath(CA_CERT_FILE_PATH)
+                .certificateFilePath(getTlsFileForClient("admin.cert"))
+                .keyFilePath(getTlsFileForClient("admin.key-pk8"))
+                // The shared test broker cert is not guaranteed to carry a SAN for the advertised host, so
+                // (as the v4 TLS suites do) hostname verification is left off.
+                .enableHostnameVerification(false)
+                .build();
+
+        @Cleanup
+        PulsarClient client = PulsarClient.builder()
+                .serviceUrl(pulsar.getBrokerServiceUrlTls())
+                .tlsPolicy(policy)
+                .authentication(AuthenticationFactory.tls())
+                .build();
+
+        assertProduceConsume(client, TOPIC + "-marker", "v5-mtls-via-marker");
+    }
+
+    private static void assertProduceConsume(PulsarClient client, String topic, String payload)
+            throws Exception {
+        @Cleanup
+        Producer<String> producer = client.newProducer(Schema.string())
+                .topic(topic)
+                .create();
+
+        @Cleanup
+        QueueConsumer<String> consumer = client.newQueueConsumer(Schema.string())
+                .topic(topic)
+                .subscriptionName("v5-tls-sub")
+                .subscribe();
+
+        producer.newMessage().value(payload).send();
+
+        Message<String> received = consumer.receive(Duration.ofSeconds(10));
+        assertThat(received).isNotNull();
+        assertThat(received.value()).isEqualTo(payload);
+    }
+}
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/AdminApiKeyStoreTlsAuthTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/AdminApiKeyStoreTlsAuthTest.java
index 546e2751ab61..e4c170a627d6 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/AdminApiKeyStoreTlsAuthTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/AdminApiKeyStoreTlsAuthTest.java
@@ -27,6 +27,10 @@ import jakarta.ws.rs.client.ClientBuilder;
 import jakarta.ws.rs.client.WebTarget;
 import jakarta.ws.rs.core.GenericType;
 import jakarta.ws.rs.core.MediaType;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.security.KeyStore;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -35,7 +39,9 @@ import java.util.Optional;
 import java.util.Properties;
 import java.util.Set;
 import javax.crypto.SecretKey;
+import javax.net.ssl.KeyManagerFactory;
 import javax.net.ssl.SSLContext;
+import javax.net.ssl.TrustManagerFactory;
 import lombok.Cleanup;
 import lombok.CustomLog;
 import org.apache.pulsar.broker.authentication.AuthenticationProviderTls;
@@ -50,7 +56,6 @@ import org.apache.pulsar.client.impl.auth.AuthenticationKeyStoreTls;
 import org.apache.pulsar.client.impl.auth.AuthenticationToken;
 import org.apache.pulsar.common.policies.data.ClusterData;
 import org.apache.pulsar.common.policies.data.TenantInfoImpl;
-import org.apache.pulsar.common.util.keystoretls.KeyStoreSSLContext;
 import org.glassfish.jersey.client.ClientConfig;
 import org.glassfish.jersey.client.ClientProperties;
 import org.glassfish.jersey.jackson.JacksonFeature;
@@ -139,7 +144,7 @@ public class AdminApiKeyStoreTlsAuthTest extends ProducerConsumerBase {
         ClientBuilder clientBuilder = ClientBuilder.newBuilder().withConfig(httpConfig)
             .register(JacksonConfigurator.class).register(JacksonFeature.class);
 
-        SSLContext sslCtx = KeyStoreSSLContext.createClientSslContext(
+        SSLContext sslCtx = createJdkClientSslContext(
                 KEYSTORE_TYPE,
                 PROXY_KEYSTORE_FILE_PATH,
                 PROXY_KEYSTORE_PW,
@@ -153,6 +158,30 @@ public class AdminApiKeyStoreTlsAuthTest extends ProducerConsumerBase {
         return client.target(brokerUrlTls.toString());
     }
 
+    // PIP-478 stage 4c: builds the Jersey test client's mutual-TLS SSLContext straight from the JDK KeyStore /
+    // SSLContext APIs (replacing the removed KeyStoreSSLContext.createClientSslContext helper).
+    private static SSLContext createJdkClientSslContext(String keyStoreType, String keyStorePath,
+            String keyStorePassword, String trustStoreType, String trustStorePath, String trustStorePassword)
+            throws Exception {
+        KeyStore keyStore = KeyStore.getInstance(keyStoreType);
+        try (InputStream in = Files.newInputStream(Paths.get(keyStorePath))) {
+            keyStore.load(in, keyStorePassword.toCharArray());
+        }
+        KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
+        kmf.init(keyStore, keyStorePassword.toCharArray());
+
+        KeyStore trustStore = KeyStore.getInstance(trustStoreType);
+        try (InputStream in = Files.newInputStream(Paths.get(trustStorePath))) {
+            trustStore.load(in, trustStorePassword.toCharArray());
+        }
+        TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+        tmf.init(trustStore);
+
+        SSLContext sslContext = SSLContext.getInstance("TLS");
+        sslContext.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null);
+        return sslContext;
+    }
+
     PulsarAdmin buildAdminClient() throws Exception {
         Map<String, String> authParams = new HashMap<>();
         authParams.put(AuthenticationKeyStoreTls.KEYSTORE_PATH, CLIENT_KEYSTORE_FILE_PATH);
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/AdminOnlyOAuth2AuthTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/AdminOnlyOAuth2AuthTest.java
new file mode 100644
index 000000000000..20954c02cd5a
--- /dev/null
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/AdminOnlyOAuth2AuthTest.java
@@ -0,0 +1,91 @@
+/*
+ * 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.pulsar.client.impl;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import java.net.URL;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Map;
+import java.util.Set;
+import lombok.Cleanup;
+import org.apache.pulsar.broker.auth.MockOIDCIdentityProvider;
+import org.apache.pulsar.client.admin.PulsarAdmin;
+import org.apache.pulsar.client.api.Authentication;
+import org.apache.pulsar.client.api.AuthenticationDataProvider;
+import org.apache.pulsar.client.impl.auth.oauth2.AuthenticationFactoryOAuth2;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+/**
+ * PIP-478: admin-only OAuth2. A {@link PulsarAdmin} built without a {@link PulsarClient} must
+ * acquire its OAuth2 token through the framework HTTP client bound by {@code PulsarAdminImpl} (mirroring
+ * {@code PulsarClientImpl}), not the removed private OAuth2 {@code AsyncHttpClient}. Building the admin runs
+ * {@code auth.start()}, which resolves the IdP metadata, and the first {@code getAuthData()} fetches the
+ * token — both over the framework client. If the binding were missing, {@code getHttpClient()} would throw
+ * "OAuth2 requires the authentication to be initialized by a PulsarClient/PulsarAdmin".
+ */
+@Test(groups = "broker-impl")
+public class AdminOnlyOAuth2AuthTest {
+
+    private static final String CREDENTIALS_FILE =
+            "./src/test/resources/authentication/token/credentials_file.json";
+    private static final String AUDIENCE = "my-pulsar-cluster";
+
+    private MockOIDCIdentityProvider server;
+
+    @BeforeMethod(alwaysRun = true)
+    public void setUp() {
+        server = new MockOIDCIdentityProvider("super-secret-client-secret", AUDIENCE, 30000);
+    }
+
+    @AfterMethod(alwaysRun = true)
+    public void tearDown() {
+        if (server != null) {
+            server.stop();
+            server = null;
+        }
+    }
+
+    @Test
+    @SuppressWarnings("deprecation")
+    public void adminOnlyOAuth2AcquiresTokenViaFrameworkClient() throws Exception {
+        Path credentials = Paths.get(CREDENTIALS_FILE).toAbsolutePath();
+        Authentication auth = AuthenticationFactoryOAuth2.clientCredentials(
+                new URL(server.getIssuer()), credentials.toUri().toURL(), AUDIENCE);
+
+        // Building the admin runs auth.start() (IdP metadata resolution); with no PulsarClient in play, that
+        // must go over the framework HTTP client bound by PulsarAdminImpl. A missing binding would throw
+        // here. The dummy service URL is never contacted during token acquisition.
+        @Cleanup
+        PulsarAdmin admin = PulsarAdmin.builder()
+                .serviceHttpUrl("http://localhost:65535")
+                .authentication(auth)
+                .build();
+
+        // The first getAuthData() fetches the token over the same framework client; a Bearer token surfaces
+        // through the auth data's HTTP headers.
+        AuthenticationDataProvider data = auth.getAuthData();
+        assertThat(data.hasDataForHttp()).isTrue();
+        Set<Map.Entry<String, String>> headers = data.getHttpHeaders();
+        assertThat(headers).anyMatch(h -> "Authorization".equals(h.getKey())
+                && h.getValue() != null && h.getValue().startsWith("Bearer "));
+    }
+}
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/KeyStoreTlsTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/KeyStoreTlsTest.java
deleted file mode 100644
index 6edcf0d60cdc..000000000000
--- a/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/KeyStoreTlsTest.java
+++ /dev/null
@@ -1,106 +0,0 @@
-/*
- * 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.pulsar.client.impl;
-
-import static org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest.BROKER_KEYSTORE_FILE_PATH;
-import static org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest.BROKER_KEYSTORE_PW;
-import static org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest.BROKER_TRUSTSTORE_FILE_PATH;
-import static org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest.BROKER_TRUSTSTORE_NO_PASSWORD_FILE_PATH;
-import static org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest.CLIENT_KEYSTORE_FILE_PATH;
-import static org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest.CLIENT_KEYSTORE_PW;
-import static org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest.CLIENT_TRUSTSTORE_FILE_PATH;
-import static org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest.CLIENT_TRUSTSTORE_NO_PASSWORD_FILE_PATH;
-import static org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest.CLIENT_TRUSTSTORE_PW;
-import static org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest.KEYSTORE_TYPE;
-import java.util.Collections;
-import org.apache.pulsar.common.util.keystoretls.KeyStoreSSLContext;
-import org.apache.pulsar.common.util.keystoretls.SSLContextValidatorEngine;
-import org.testng.annotations.Test;
-
-@Test(groups = "broker-impl")
-public class KeyStoreTlsTest {
-
-    @Test(timeOut = 300000)
-    public void testValidate() throws Exception {
-        KeyStoreSSLContext serverSSLContext = new KeyStoreSSLContext(KeyStoreSSLContext.Mode.SERVER,
-                null,
-                KEYSTORE_TYPE,
-                BROKER_KEYSTORE_FILE_PATH,
-                BROKER_KEYSTORE_PW,
-                false,
-                KEYSTORE_TYPE,
-                CLIENT_TRUSTSTORE_FILE_PATH,
-                CLIENT_TRUSTSTORE_PW,
-                true,
-                null,
-                null);
-        serverSSLContext.createSSLContext();
-
-        KeyStoreSSLContext clientSSLContext = new KeyStoreSSLContext(KeyStoreSSLContext.Mode.CLIENT,
-                null,
-                KEYSTORE_TYPE,
-                CLIENT_KEYSTORE_FILE_PATH,
-                CLIENT_KEYSTORE_PW,
-                false,
-                KEYSTORE_TYPE,
-                BROKER_TRUSTSTORE_FILE_PATH,
-                BROKER_KEYSTORE_PW,
-                false,
-                null,
-                // set client's protocol to TLSv1.2 since SSLContextValidatorEngine.validate doesn't handle TLSv1.3
-                Collections.singleton("TLSv1.2"));
-        clientSSLContext.createSSLContext();
-
-        SSLContextValidatorEngine.validate(clientSSLContext::createSSLEngine, serverSSLContext::createSSLEngine);
-    }
-
-    @Test(timeOut = 300000)
-    public void testValidateKeyStoreNoPwd() throws Exception {
-        KeyStoreSSLContext serverSSLContext = new KeyStoreSSLContext(KeyStoreSSLContext.Mode.SERVER,
-                null,
-                KEYSTORE_TYPE,
-                BROKER_KEYSTORE_FILE_PATH,
-                BROKER_KEYSTORE_PW,
-                false,
-                KEYSTORE_TYPE,
-                CLIENT_TRUSTSTORE_NO_PASSWORD_FILE_PATH,
-                null,
-                true,
-                null,
-                null);
-        serverSSLContext.createSSLContext();
-
-        KeyStoreSSLContext clientSSLContext = new KeyStoreSSLContext(KeyStoreSSLContext.Mode.CLIENT,
-                null,
-                KEYSTORE_TYPE,
-                CLIENT_KEYSTORE_FILE_PATH,
-                CLIENT_KEYSTORE_PW,
-                false,
-                KEYSTORE_TYPE,
-                BROKER_TRUSTSTORE_NO_PASSWORD_FILE_PATH,
-                null,
-                false,
-                null,
-                // set client's protocol to TLSv1.2 since SSLContextValidatorEngine.validate doesn't handle TLSv1.3
-                Collections.singleton("TLSv1.2"));
-        clientSSLContext.createSSLContext();
-
-        SSLContextValidatorEngine.validate(clientSSLContext::createSSLEngine, serverSSLContext::createSSLEngine);
-    }
-}
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/functions/worker/WorkerServerTlsFactoryTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/functions/worker/WorkerServerTlsFactoryTest.java
new file mode 100644
index 000000000000..5efc193d8f54
--- /dev/null
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/functions/worker/WorkerServerTlsFactoryTest.java
@@ -0,0 +1,98 @@
+/*
+ * 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.pulsar.functions.worker;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import org.apache.pulsar.broker.authentication.AuthenticationService;
+import org.apache.pulsar.broker.tls.CountingWebTlsFactory;
+import org.apache.pulsar.common.util.tls.JdkSslContexts;
+import org.apache.pulsar.common.util.tls.PemReader;
+import org.apache.pulsar.functions.worker.rest.WorkerServer;
+import org.apache.pulsar.utils.ResourceUtils;
+import org.eclipse.jetty.client.ContentResponse;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.util.ssl.SslContextFactory;
+import org.testng.annotations.Test;
+
+/**
+ * PIP-478 (T5): the functions-worker {@link WorkerServer} serves its WEB (HTTPS) listener through a custom
+ * {@link org.apache.pulsar.tls.PulsarTlsFactory} selected via {@code tlsFactoryClassName}. A counting
+ * factory proves the new SPI path is exercised (its {@code createInstance} is invoked while building the WEB
+ * SslContextFactory) and a successful HTTPS request against the unauthenticated {@code /version} endpoint
+ * proves the synthesized web TLS listener actually works. The {@link WorkerService} is mocked, so no broker /
+ * bookkeeper is started — the worker's Jetty web server alone.
+ */
+public class WorkerServerTlsFactoryTest {
+
+    private static final String CA = ResourceUtils.getAbsolutePath("certificate-authority/certs/ca.cert.pem");
+    private static final String CERT =
+            ResourceUtils.getAbsolutePath("certificate-authority/server-keys/broker.cert.pem");
+    private static final String KEY =
+            ResourceUtils.getAbsolutePath("certificate-authority/server-keys/broker.key-pk8.pem");
+
+    @Test
+    public void webListenerServedThroughCustomTlsFactory() throws Exception {
+        int before = CountingWebTlsFactory.createInstanceCount();
+
+        WorkerConfig workerConfig = new WorkerConfig();
+        workerConfig.setWorkerHostname("localhost");
+        workerConfig.setWorkerPort(null);
+        workerConfig.setWorkerPortTls(0);
+        workerConfig.setTlsEnabled(true);
+        workerConfig.setTlsCertificateFilePath(CERT);
+        workerConfig.setTlsKeyFilePath(KEY);
+        workerConfig.setTlsTrustCertsFilePath(CA);
+        workerConfig.setTlsFactoryClassName(CountingWebTlsFactory.class.getName());
+        workerConfig.setTlsFactoryConfig("trust=" + CA + ",cert=" + CERT + ",key=" + KEY);
+
+        WorkerService workerService = mock(WorkerService.class);
+        when(workerService.getWorkerConfig()).thenReturn(workerConfig);
+        AuthenticationService authenticationService = mock(AuthenticationService.class);
+
+        WorkerServer workerServer = new WorkerServer(workerService, authenticationService);
+        workerServer.start();
+        try {
+            int port = workerServer.getListenPortHTTPS().orElseThrow();
+            ContentResponse response = httpsGet("https://localhost:" + port + "/version");
+            assertThat(response.getStatus()).isEqualTo(200);
+            assertThat(response.getContentAsString()).contains("version");
+            assertThat(CountingWebTlsFactory.createInstanceCount())
+                    .as("the WEB purpose was served through the custom tlsFactoryClassName")
+                    .isGreaterThan(before);
+        } finally {
+            workerServer.stop();
+        }
+    }
+
+    private static ContentResponse httpsGet(String url) throws Exception {
+        SslContextFactory.Client sslContextFactory = new SslContextFactory.Client();
+        sslContextFactory.setSslContext(JdkSslContexts.createSslContext(
+                false, PemReader.loadCertificatesFromPemFile(CA), null));
+        HttpClient httpClient = new HttpClient();
+        httpClient.setSslContextFactory(sslContextFactory);
+        httpClient.start();
+        try {
+            return httpClient.newRequest(url).send();
+        } finally {
+            httpClient.stop();
+        }
+    }
+}
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyPublishConsumeTlsTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyPublishConsumeTlsTest.java
index e487735d5bab..cc4a83596363 100644
--- a/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyPublishConsumeTlsTest.java
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/ProxyPublishConsumeTlsTest.java
@@ -31,7 +31,8 @@ import lombok.Cleanup;
 import lombok.CustomLog;
 import org.apache.pulsar.client.api.TlsProducerConsumerBase;
 import org.apache.pulsar.client.impl.auth.AuthenticationTls;
-import org.apache.pulsar.common.util.SecurityUtility;
+import org.apache.pulsar.common.util.tls.JdkSslContexts;
+import org.apache.pulsar.common.util.tls.PemReader;
 import org.apache.pulsar.metadata.impl.ZKMetadataStore;
 import org.apache.pulsar.websocket.WebSocketService;
 import org.apache.pulsar.websocket.service.ProxyServer;
@@ -106,8 +107,8 @@ public class ProxyPublishConsumeTlsTest extends TlsProducerConsumerBase {
         URI produceUri = URI.create(producerUri);
 
         SslContextFactory.Client sslContextFactory = new SslContextFactory.Client();
-        sslContextFactory.setSslContext(SecurityUtility
-                .createSslContext(false, SecurityUtility.loadCertificatesFromPemFile(CA_CERT_FILE_PATH), null));
+        sslContextFactory.setSslContext(JdkSslContexts
+                .createSslContext(false, PemReader.loadCertificatesFromPemFile(CA_CERT_FILE_PATH), null));
         @Cleanup("stop")
         HttpClient httpClient = new HttpClient();
         httpClient.setSslContextFactory(sslContextFactory);
diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/WebSocketProxyTlsFactoryTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/WebSocketProxyTlsFactoryTest.java
new file mode 100644
index 000000000000..12869cc9f711
--- /dev/null
+++ b/pulsar-broker/src/test/java/org/apache/pulsar/websocket/proxy/WebSocketProxyTlsFactoryTest.java
@@ -0,0 +1,92 @@
+/*
+ * 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.pulsar.websocket.proxy;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import java.util.Optional;
+import org.apache.pulsar.broker.tls.CountingWebTlsFactory;
+import org.apache.pulsar.common.configuration.VipStatus;
+import org.apache.pulsar.common.util.tls.JdkSslContexts;
+import org.apache.pulsar.common.util.tls.PemReader;
+import org.apache.pulsar.utils.ResourceUtils;
+import org.apache.pulsar.websocket.service.ProxyServer;
+import org.apache.pulsar.websocket.service.WebSocketProxyConfiguration;
+import org.eclipse.jetty.client.ContentResponse;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.util.ssl.SslContextFactory;
+import org.testng.annotations.Test;
+
+/**
+ * PIP-478 (T5): the standalone WebSocket proxy serves its WEB (HTTPS) listener through a custom
+ * {@link org.apache.pulsar.tls.PulsarTlsFactory} selected via {@code tlsFactoryClassName}. A counting
+ * factory proves the new SPI path is exercised (its {@code createInstance} is invoked while building the WEB
+ * SslContextFactory) and a successful HTTPS request against the {@code /status.html} endpoint proves the
+ * synthesized web TLS listener actually works. No broker is started — the proxy's Jetty web server alone.
+ */
+@Test(groups = "websocket")
+public class WebSocketProxyTlsFactoryTest {
+
+    private static final String CA = ResourceUtils.getAbsolutePath("certificate-authority/certs/ca.cert.pem");
+    private static final String CERT =
+            ResourceUtils.getAbsolutePath("certificate-authority/server-keys/broker.cert.pem");
+    private static final String KEY =
+            ResourceUtils.getAbsolutePath("certificate-authority/server-keys/broker.key-pk8.pem");
+
+    @Test
+    public void webListenerServedThroughCustomTlsFactory() throws Exception {
+        int before = CountingWebTlsFactory.createInstanceCount();
+
+        WebSocketProxyConfiguration config = new WebSocketProxyConfiguration();
+        config.setClusterName("test");
+        config.setWebServicePort(Optional.empty());
+        config.setWebServicePortTls(Optional.of(0));
+        config.setTlsFactoryClassName(CountingWebTlsFactory.class.getName());
+        config.setTlsFactoryConfig("trust=" + CA + ",cert=" + CERT + ",key=" + KEY);
+        config.setStatusFilePath(CA);
+
+        ProxyServer proxyServer = new ProxyServer(config);
+        proxyServer.addRestResource("/", VipStatus.ATTRIBUTE_STATUS_FILE_PATH, CA, VipStatus.class);
+        proxyServer.start();
+        try {
+            int port = proxyServer.getListenPortHTTPS().orElseThrow();
+            ContentResponse response = httpsGet("https://localhost:" + port + "/status.html");
+            assertThat(response.getStatus()).isEqualTo(200);
+            assertThat(response.getContentAsString()).isEqualTo("OK");
+            assertThat(CountingWebTlsFactory.createInstanceCount())
+                    .as("the WEB purpose was served through the custom tlsFactoryClassName")
+                    .isGreaterThan(before);
+        } finally {
+            proxyServer.stop();
+        }
+    }
+
+    private static ContentResponse httpsGet(String url) throws Exception {
+        SslContextFactory.Client sslContextFactory = new SslContextFactory.Client();
+        sslContextFactory.setSslContext(JdkSslContexts.createSslContext(
+                false, PemReader.loadCertificatesFromPemFile(CA), null));
+        HttpClient httpClient = new HttpClient();
+        httpClient.setSslContextFactory(sslContextFactory);
+        httpClient.start();
+        try {
+            return httpClient.newRequest(url).send();
+        } finally {
+            httpClient.stop();
+        }
+    }
+}
diff --git a/pulsar-client-admin-api/src/main/java/org/apache/pulsar/client/admin/PulsarAdminBuilder.java b/pulsar-client-admin-api/src/main/java/org/apache/pulsar/client/admin/PulsarAdminBuilder.java
index 8332952bef72..a9fb95a12111 100644
--- a/pulsar-client-admin-api/src/main/java/org/apache/pulsar/client/admin/PulsarAdminBuilder.java
+++ b/pulsar-client-admin-api/src/main/java/org/apache/pulsar/client/admin/PulsarAdminBuilder.java
@@ -293,18 +293,25 @@ public interface PulsarAdminBuilder {
     PulsarAdminBuilder tlsProtocols(Set<String> tlsProtocols);
 
     /**
-     * SSL Factory Plugin used to generate the SSL Context and SSLEngine.
-     * @param sslFactoryPlugin Name of the SSL Factory Class to be used.
-     * @return PulsarAdminBuilder
+     * Set the class name of a custom {@code PulsarTlsFactory} (PIP-478) used to build the admin client's TLS
+     * engines. An empty value or the literal {@code "default"} selects the built-in file-based factory
+     * composed from the {@code tls*} settings; any other value is instantiated reflectively via its public
+     * no-arg constructor. This is the by-name successor of the removed PIP-337 {@code sslFactoryPlugin}.
+     *
+     * @param tlsFactoryClassName the {@code PulsarTlsFactory} class name (blank/{@code default} for built-in)
+     * @return the admin builder instance
      */
-    PulsarAdminBuilder sslFactoryPlugin(String sslFactoryPlugin);
+    PulsarAdminBuilder tlsFactoryClassName(String tlsFactoryClassName);
 
     /**
-     * Parameters used by the SSL Factory Plugin class.
-     * @param sslFactoryPluginParams String parameters to be used by the SSL Factory Class.
-     * @return
+     * Set the configuration parameters passed to a custom {@link #tlsFactoryClassName(String)} as its init
+     * params (PIP-478). Accepts a JSON object or a comma-separated {@code key=value} list; ignored by the
+     * built-in file-based factory.
+     *
+     * @param tlsFactoryConfig the factory configuration (JSON object or {@code key=value} list)
+     * @return the admin builder instance
      */
-    PulsarAdminBuilder sslFactoryPluginParams(String sslFactoryPluginParams);
+    PulsarAdminBuilder tlsFactoryConfig(String tlsFactoryConfig);
 
     /**
      * This sets the connection time out for the pulsar admin client.
diff --git a/pulsar-client-admin-api/src/main/java/org/apache/pulsar/common/policies/data/ClusterData.java b/pulsar-client-admin-api/src/main/java/org/apache/pulsar/common/policies/data/ClusterData.java
index 6aeed746db42..65571526f385 100644
--- a/pulsar-client-admin-api/src/main/java/org/apache/pulsar/common/policies/data/ClusterData.java
+++ b/pulsar-client-admin-api/src/main/java/org/apache/pulsar/common/policies/data/ClusterData.java
@@ -65,8 +65,48 @@ public interface ClusterData {
 
     String getBrokerClientTlsKeyStore();
 
+    /**
+     * The {@code PulsarTlsFactory} class name used for outbound connections to this cluster (PIP-478), or
+     * blank to inherit the broker-level {@code brokerClientTlsFactoryClassName}. It applies to the two
+     * outbound legs whose client configuration is <em>built from this cluster entry</em>: the
+     * binary-protocol replication client and the cross-cluster admin (HTTPS) client.
+     *
+     * <p>It does not reach the peer-cluster lookup client ({@code NamespaceService.getNamespaceClient}),
+     * which takes only the service URL from the cluster entry and all of its TLS configuration — material
+     * and factory alike — from the broker-level {@code brokerClient*} settings. That leg was already
+     * broker-level in 4.x (it read the broker-level {@code brokerClientSslFactoryPlugin}, never the
+     * per-cluster one), so this is unchanged behaviour rather than a PIP-478 narrowing.
+     *
+     * @return the per-cluster TLS factory class name, or blank/null to inherit the broker-level setting
+     */
+    String getBrokerClientTlsFactoryClassName();
+
+    /**
+     * The configuration parameters passed to {@link #getBrokerClientTlsFactoryClassName()} as its init
+     * params (a JSON object or a {@code key=value} list).
+     *
+     * <p>This value follows the class name rather than inheriting on its own: when this cluster names a
+     * factory, this configuration is used even when blank, so factory A's parameters can never be handed to
+     * factory B. Only when the class name is blank — so the broker-level factory applies — does the
+     * broker-level {@code brokerClientTlsFactoryConfig} apply too.
+     *
+     * @return the per-cluster TLS factory configuration; used verbatim when this cluster names a factory
+     */
+    String getBrokerClientTlsFactoryConfig();
+
+    /**
+     * @deprecated since 5.0.0: the PIP-337 SSL factory plugin is removed (PIP-478). Retained in the cluster
+     *     metadata schema for wire/metadata compatibility, but a configured value is ignored (with a WARN).
+     *     Use {@link #getBrokerClientTlsFactoryClassName()} instead.
+     */
+    @Deprecated
     String getBrokerClientSslFactoryPlugin();
 
+    /**
+     * @deprecated since 5.0.0: the PIP-337 SSL factory plugin is removed (PIP-478). Retained for metadata
+     *     compatibility but ignored. Use {@link #getBrokerClientTlsFactoryConfig()} instead.
+     */
+    @Deprecated
     String getBrokerClientSslFactoryPluginParams();
 
     String getListenerName();
@@ -116,8 +156,44 @@ public interface ClusterData {
 
         Builder listenerName(String listenerName);
 
+        /**
+         * Select the {@code PulsarTlsFactory} used for outbound connections to this cluster (PIP-478) —
+         * the binary-protocol replication client and the cross-cluster admin (HTTPS) client, the two legs
+         * whose configuration is built from this cluster entry (see
+         * {@link ClusterData#getBrokerClientTlsFactoryClassName()} for the leg it does not reach). Leave
+         * blank to inherit the broker-level {@code brokerClientTlsFactoryClassName}.
+         *
+         * @param tlsFactoryClassName the factory class name, or blank to inherit the broker-level setting
+         * @return this builder
+         */
+        Builder brokerClientTlsFactoryClassName(String tlsFactoryClassName);
+
+        /**
+         * Configuration passed to {@link #brokerClientTlsFactoryClassName(String)} as its init params (a
+         * JSON object or a {@code key=value} list).
+         *
+         * <p>It follows the class name rather than inheriting on its own: setting a class name here means
+         * this configuration is used even when blank, so one factory's parameters are never handed to
+         * another. It is ignored — with a warning — when no class name is set on this cluster.
+         *
+         * @param tlsFactoryConfig the factory configuration for this cluster's factory
+         * @return this builder
+         */
+        Builder brokerClientTlsFactoryConfig(String tlsFactoryConfig);
+
+        /**
+         * @deprecated since 5.0.0: the PIP-337 SSL factory plugin is removed (PIP-478). Setting it writes a
+         *     metadata field retained only for compatibility; the value is ignored (with a WARN). Use
+         *     {@link #brokerClientTlsFactoryClassName(String)} instead.
+         */
+        @Deprecated
         Builder brokerClientSslFactoryPlugin(String sslFactoryPlugin);
 
+        /**
+         * @deprecated since 5.0.0: the PIP-337 SSL factory plugin is removed (PIP-478). Retained for
+         *     metadata compatibility but ignored. Use {@link #brokerClientTlsFactoryConfig(String)} instead.
+         */
+        @Deprecated
         Builder brokerClientSslFactoryPluginParams(String sslFactoryPluginParams);
 
         ClusterData build();
diff --git a/pulsar-client-admin/build.gradle.kts b/pulsar-client-admin/build.gradle.kts
index b82bdf95708e..7a08a5226d4e 100644
--- a/pulsar-client-admin/build.gradle.kts
+++ b/pulsar-client-admin/build.gradle.kts
@@ -19,6 +19,9 @@
 
 plugins {
     id("pulsar.public-java-library-conventions")
+    // PIP-478: AdminOAuth2IdpTlsEndToEndTest stands up a WireMock HTTPS IdP with the shared CA test certs
+    // (tests/certificate-authority), proving the admin's folded CLIENT_OAUTH2 trust reaches the IdP.
+    id("pulsar.test-certs-conventions")
 }
 
 dependencies {
@@ -26,6 +29,11 @@ dependencies {
     api(project(":pulsar-client-admin-api"))
     api(project(":pulsar-client-original"))
     api(project(":pulsar-common"))
+    // PIP-478: AsyncHttpConnector (internal) uses the TLS factory SPI directly (PulsarTlsFactory / TlsHandle /
+    // TlsPurpose) but never surfaces it on this module's exported ABI, so it is `implementation`. The HTTP SPI
+    // is not named directly here (only pulsar-client's FrameworkHttpClientFactory is referenced), so no direct
+    // http-client-api dependency is needed.
+    implementation(project(":pulsar-tls-factory-api"))
     implementation(project(":pulsar-package-management:pulsar-package-core"))
     api(libs.jersey.client)
     implementation(libs.jersey.media.json.jackson)
@@ -42,6 +50,11 @@ dependencies {
     api(libs.asynchttpclient)
     implementation(libs.commons.lang3)
     implementation(libs.completable.futures)
+    // PIP-478 stage 4b: the admin AsyncHttpConnector rides the PIP-478 TLS SPI on the new path, whose init
+    // context carries an OpenTelemetry root (compile-only; the real root is supplied at runtime by the owning
+    // component, matching pulsar-common / pulsar-tls-factory-api).
+    compileOnly(libs.opentelemetry.api)
 
     testImplementation(libs.wiremock)
+    testImplementation(libs.opentelemetry.api)
 }
diff --git a/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/BaseResource.java b/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/BaseResource.java
index 5cfb785c5854..feee6417c21b 100644
--- a/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/BaseResource.java
+++ b/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/BaseResource.java
@@ -29,10 +29,15 @@ import jakarta.ws.rs.client.WebTarget;
 import jakarta.ws.rs.core.GenericType;
 import jakarta.ws.rs.core.MediaType;
 import jakarta.ws.rs.core.Response;
+import java.net.URI;
+import java.time.Duration;
+import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.Map.Entry;
+import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
@@ -51,6 +56,8 @@ import org.apache.pulsar.client.admin.PulsarAdminException.ServerSideErrorExcept
 import org.apache.pulsar.client.api.Authentication;
 import org.apache.pulsar.client.api.AuthenticationDataProvider;
 import org.apache.pulsar.client.api.PulsarClientException;
+import org.apache.pulsar.client.impl.auth.v5.AsyncHttpAuthenticationProvider;
+import org.apache.pulsar.client.impl.auth.v5.HttpAuthenticationDriver;
 import org.apache.pulsar.common.policies.data.ErrorData;
 import org.apache.pulsar.common.util.ObjectMapperFactory;
 
@@ -79,34 +86,20 @@ public abstract class BaseResource {
     // do the authentication stage, and once authentication completed return a Builder
     public CompletableFuture<Builder> requestAsync(final WebTarget target) {
         CompletableFuture<Builder> builderFuture = new CompletableFuture<>();
-        CompletableFuture<Map<String, String>> authFuture = new CompletableFuture<>();
         try {
-            AuthenticationDataProvider authData = auth.getAuthData(target.getUri().getHost());
-
-            if (authData.hasDataForHttp()) {
-                auth.authenticationStage(target.getUri().toString(), authData, null, authFuture);
-            } else {
-                authFuture.complete(null);
-            }
-
-            // auth complete, return a new Builder
-            authFuture.whenComplete((respHeaders, ex) -> {
+            computeAuthHeaders(target.getUri()).whenComplete((authHeaders, ex) -> {
                 if (ex != null) {
                     log.warn().attr("uri", target.getUri())
                             .exceptionMessage(ex)
                             .log("Failed to perform http request at auth stage");
-                    builderFuture.completeExceptionally(new PulsarClientException(ex));
+                    builderFuture.completeExceptionally(new PulsarClientException(unwrapCompletion(ex)));
                     return;
                 }
 
                 try {
                     Builder builder = target.request(MediaType.APPLICATION_JSON);
-                    if (authData.hasDataForHttp()) {
-                        Set<Entry<String, String>> headers =
-                            auth.newRequestHeader(target.getUri().toString(), authData, respHeaders);
-                        if (headers != null) {
-                            headers.forEach(entry -> builder.header(entry.getKey(), entry.getValue()));
-                        }
+                    if (authHeaders != null) {
+                        authHeaders.forEach((name, value) -> builder.header(name, value));
                     }
                     builderFuture.complete(builder);
                 } catch (Throwable t) {
@@ -120,6 +113,58 @@ public abstract class BaseResource {
         return builderFuture;
     }
 
+    /**
+     * Compute the authentication headers to attach to the outgoing admin request, or a future of
+     * {@code null} when the plugin contributes none (PIP-478).
+     *
+     * <p>When the plugin exposes the v5-native SASL-over-HTTP capability (via the
+     * {@link AsyncHttpAuthenticationProvider} bridge), the framework {@link HttpAuthenticationDriver} runs
+     * the bounded {@code 401}→resubmit→{@code 200} exchange over the plugin's own HTTP client — a bodiless
+     * {@code GET} to the original URI each round, exactly what the v4 {@code authenticationStage(...)} does
+     * today — and yields the validated role-token headers. Otherwise the deprecated v4
+     * {@code authenticationStage(...)} / {@code newRequestHeader(...)} hooks run verbatim, preserving
+     * behaviour for third-party plugins and single-pass built-ins.
+     */
+    protected CompletableFuture<Map<String, String>> computeAuthHeaders(URI uri) {
+        try {
+            if (auth instanceof AsyncHttpAuthenticationProvider provider) {
+                Optional<HttpAuthenticationDriver> driver = provider.httpAuthenticationDriver()
+                        .filter(HttpAuthenticationDriver::supportsHttpChallenge);
+                if (driver.isPresent()) {
+                    Duration budget = Duration.ofMillis(requestTimeoutMs > 0 ? requestTimeoutMs : 60_000L);
+                    // Null transport => the driver uses the plugin's own default transport (its JAX-RS client).
+                    return driver.get().authenticateAsync(uri, null, budget)
+                            .thenApply(headers -> (headers == null || headers.isEmpty()) ? null : headers.asMap());
+                }
+            }
+            AuthenticationDataProvider authData = auth.getAuthData(uri.getHost());
+            if (!authData.hasDataForHttp()) {
+                return CompletableFuture.completedFuture(null);
+            }
+            CompletableFuture<Map<String, String>> stage = new CompletableFuture<>();
+            auth.authenticationStage(uri.toString(), authData, null, stage);
+            return stage.thenApply(respHeaders -> {
+                try {
+                    Set<Entry<String, String>> headers = auth.newRequestHeader(uri.toString(), authData, respHeaders);
+                    if (headers == null) {
+                        return null;
+                    }
+                    Map<String, String> map = new LinkedHashMap<>();
+                    headers.forEach(entry -> map.put(entry.getKey(), entry.getValue()));
+                    return map;
+                } catch (Exception e) {
+                    throw new CompletionException(e);
+                }
+            });
+        } catch (Throwable t) {
+            return CompletableFuture.failedFuture(t);
+        }
+    }
+
+    private static Throwable unwrapCompletion(Throwable ex) {
+        return (ex instanceof CompletionException && ex.getCause() != null) ? ex.getCause() : ex;
+    }
+
     public <T> CompletableFuture<Void> asyncPutRequest(final WebTarget target, Entity<T> entity) {
         final CompletableFuture<Void> future = new CompletableFuture<>();
         try {
diff --git a/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/ComponentResource.java b/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/ComponentResource.java
index ae60c30b25cd..f1be4471fc71 100644
--- a/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/ComponentResource.java
+++ b/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/ComponentResource.java
@@ -24,11 +24,8 @@ import jakarta.ws.rs.client.WebTarget;
 import java.util.Map;
 import java.util.Map.Entry;
 import java.util.Set;
-import java.util.concurrent.CompletableFuture;
 import org.apache.pulsar.client.admin.PulsarAdminException;
 import org.apache.pulsar.client.api.Authentication;
-import org.apache.pulsar.client.api.AuthenticationDataProvider;
-import org.apache.pulsar.common.sasl.SaslConstants;
 import org.apache.pulsar.common.util.ObjectMapperFactory;
 import org.asynchttpclient.RequestBuilder;
 
@@ -57,17 +54,13 @@ public class ComponentResource extends BaseResource {
     }
 
     private Set<Entry<String, String>> getAuthHeaders(WebTarget target) throws Exception {
-        AuthenticationDataProvider authData = auth.getAuthData(target.getUri().getHost());
-        String targetUrl = target.getUri().toString();
-        if (auth.getAuthMethodName().equalsIgnoreCase(SaslConstants.AUTH_METHOD_NAME)) {
-            CompletableFuture<Map<String, String>> authFuture = new CompletableFuture<>();
-            auth.authenticationStage(targetUrl, authData, null, authFuture);
-            return auth.newRequestHeader(targetUrl, authData, authFuture.get());
-        } else if (authData.hasDataForHttp()) {
-            return auth.newRequestHeader(targetUrl, authData, null);
-        } else {
-            return null;
-        }
+        // The blocking get() runs on the caller's admin/application thread (addAuthHeaders is invoked
+        // synchronously from the raw-AsyncHttpClient admin operations), never the Netty event loop, and the
+        // SASL warmup completes on the plugin's own JAX-RS client pool — so this cannot deadlock. This
+        // mirrors the blocking authFuture.get() it replaces, now routed through the shared HTTP auth driver
+        // (PIP-478) when the plugin exposes the v5 SASL-over-HTTP capability.
+        Map<String, String> headers = computeAuthHeaders(target.getUri()).get();
+        return (headers == null || headers.isEmpty()) ? null : headers.entrySet();
     }
 
     protected ObjectWriter objectWriter() {
diff --git a/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/PulsarAdminBuilderImpl.java b/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/PulsarAdminBuilderImpl.java
index 46ebc1ec04e9..eb2e0669ec36 100644
--- a/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/PulsarAdminBuilderImpl.java
+++ b/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/PulsarAdminBuilderImpl.java
@@ -73,6 +73,8 @@ public class PulsarAdminBuilderImpl implements PulsarAdminBuilder {
 
     @Override
     public PulsarAdminBuilder loadConf(Map<String, Object> config) {
+        // PIP-478: reject a stale, removed PIP-337 sslFactoryPlugin key with an actionable message.
+        ConfigurationDataUtils.rejectRemovedPip337TlsFactoryKeys(config);
         conf = ConfigurationDataUtils.loadData(config, conf, ClientConfigurationData.class);
         setAuthenticationFromPropsIfAvailable(conf);
         if (config.containsKey("acceptGzipCompression")) {
@@ -224,16 +226,14 @@ public class PulsarAdminBuilderImpl implements PulsarAdminBuilder {
     }
 
     @Override
-    public PulsarAdminBuilder sslFactoryPlugin(String sslFactoryPlugin) {
-        if (StringUtils.isNotBlank(sslFactoryPlugin)) {
-            conf.setSslFactoryPlugin(sslFactoryPlugin);
-        }
+    public PulsarAdminBuilder tlsFactoryClassName(String tlsFactoryClassName) {
+        conf.setTlsFactoryClassName(StringUtils.isBlank(tlsFactoryClassName) ? "" : tlsFactoryClassName);
         return this;
     }
 
     @Override
-    public PulsarAdminBuilder sslFactoryPluginParams(String sslFactoryPluginParams) {
-        conf.setSslFactoryPluginParams(sslFactoryPluginParams);
+    public PulsarAdminBuilder tlsFactoryConfig(String tlsFactoryConfig) {
+        conf.setTlsFactoryConfig(StringUtils.isBlank(tlsFactoryConfig) ? "" : tlsFactoryConfig);
         return this;
     }
 
diff --git a/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/PulsarAdminImpl.java b/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/PulsarAdminImpl.java
index f1f1cdbe35fe..79ad64e59fde 100644
--- a/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/PulsarAdminImpl.java
+++ b/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/PulsarAdminImpl.java
@@ -20,11 +20,14 @@ package org.apache.pulsar.client.admin.internal;
 
 import static com.google.common.base.Preconditions.checkArgument;
 import com.google.common.annotations.VisibleForTesting;
+import io.opentelemetry.api.OpenTelemetry;
 import jakarta.ws.rs.client.Client;
 import jakarta.ws.rs.client.ClientBuilder;
 import jakarta.ws.rs.client.WebTarget;
 import java.io.IOException;
 import java.net.URL;
+import java.time.Clock;
+import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.concurrent.TimeUnit;
 import lombok.CustomLog;
@@ -60,10 +63,18 @@ import org.apache.pulsar.client.admin.internal.http.AsyncHttpConnectorProvider;
 import org.apache.pulsar.client.api.Authentication;
 import org.apache.pulsar.client.api.AuthenticationFactory;
 import org.apache.pulsar.client.api.PulsarClientException;
+import org.apache.pulsar.client.api.v5.internal.ClientAuthenticationServices;
+import org.apache.pulsar.client.api.v5.internal.ClientAuthenticationServicesAware;
 import org.apache.pulsar.client.impl.PulsarClientSharedResourcesImpl;
 import org.apache.pulsar.client.impl.auth.AuthenticationDisabled;
+import org.apache.pulsar.client.impl.auth.oauth2.AuthenticationOAuth2;
+import org.apache.pulsar.client.impl.auth.v5.DefaultClientAuthenticationServices;
+import org.apache.pulsar.client.impl.auth.v5.FrameworkHttpClientFactory;
 import org.apache.pulsar.client.impl.conf.ClientConfigurationData;
 import org.apache.pulsar.common.net.ServiceURI;
+import org.apache.pulsar.tls.PulsarTlsFactory;
+import org.apache.pulsar.tls.TlsPolicy;
+import org.apache.pulsar.tls.TlsPurpose;
 import org.glassfish.jersey.client.ClientConfig;
 import org.glassfish.jersey.client.ClientProperties;
 import org.glassfish.jersey.jackson.JacksonFeature;
@@ -108,6 +119,10 @@ public class PulsarAdminImpl implements PulsarAdmin {
     private final ScalableTopics scalableTopics;
     protected final WebTarget root;
     protected final Authentication auth;
+    // PIP-478: the framework HTTP client factory bound into a services-aware auth plugin
+    // (e.g. OAuth2) so an admin-only client acquires tokens over the framework client; null when the auth
+    // plugin does not implement ClientAuthenticationServicesAware. Closed with this admin.
+    private FrameworkHttpClientFactory authHttpClientFactory;
     @Getter
     private AsyncHttpConnectorProvider asyncConnectorProvider;
 
@@ -128,15 +143,22 @@ public class PulsarAdminImpl implements PulsarAdmin {
                 .attr("authMethodName", auth.getAuthMethodName())
                 .log("created");
 
-        this.auth.start();
-
         if (clientConfigData != null && StringUtils.isBlank(clientConfigData.getServiceUrl())) {
             clientConfigData.setServiceUrl(serviceUrl);
         }
 
+        // Built before the authentication is bound and started, because both may already need the TLS
+        // factory it owns: an OAuth2 plugin's start() fetches IdP metadata over HTTPS through the framework
+        // HTTP client, which resolves CLIENT_OAUTH2 against this provider's factory. Resolving the service
+        // URL first is part of that — the provider reads it to decide whether a factory is needed at all.
         asyncConnectorProvider = new AsyncHttpConnectorProvider(clientConfigData,
                 clientConfigData.getAutoCertRefreshSeconds(), acceptGzipCompression);
 
+        boolean constructed = false;
+        try {
+        bindAuthenticationServices(clientConfigData);
+        this.auth.start();
+
         ClientConfig httpConfig = new ClientConfig();
         httpConfig.property(ClientProperties.FOLLOW_REDIRECTS, true);
         httpConfig.property(ClientProperties.ASYNC_THREADPOOL_SIZE, 8);
@@ -197,6 +219,17 @@ public class PulsarAdminImpl implements PulsarAdmin {
         if (originalCtxLoader != null) {
             Thread.currentThread().setContextClassLoader(originalCtxLoader);
         }
+        constructed = true;
+        } finally {
+            if (!constructed) {
+                // close() is unreachable when the constructor throws, and the provider owns live resources
+                // from the moment its factory is resolved — the TLS factory itself plus a non-daemon
+                // "pulsar-admin-tls-factory" rotation thread. Without this, every failed build() (a bad
+                // trustCertsFilePath being the common case) leaks one of each. The connectors release only
+                // their own borrowed handles, which is exactly why this has to happen here.
+                asyncConnectorProvider.close();
+            }
+        }
     }
 
     /**
@@ -442,6 +475,121 @@ public class PulsarAdminImpl implements PulsarAdmin {
     /**
      * Close the Pulsar admin client to release all the resources.
      */
+    /**
+     * PIP-478: bind the framework HTTP client factory into a services-aware auth plugin (e.g.
+     * OAuth2) before {@code auth.start()}, which eagerly acquires a token via the flow's HTTP client. A
+     * {@code PulsarAdmin} used without a {@code PulsarClient} previously rode the now-removed private OAuth2
+     * {@code AsyncHttpClient}; this mirrors {@code PulsarClientImpl.bindAuthenticationServices} so admin-only
+     * OAuth2 acquires tokens over the framework client. The admin transport is HTTP-only, so the shared event
+     * loop / timer / DNS resolver are not threaded here (AsyncHttpClient provisions its own, as the removed
+     * private client did); the TLS factory supplier reads the admin's attached factory (broker admin path)
+     * when present.
+     */
+    /**
+     * Ensure the admin's TLS factory can serve {@link TlsPurpose#CLIENT_OAUTH2} when the configured OAuth2
+     * plugin carries its own IdP TLS material (PIP-478, issue #24944).
+     *
+     * <p>This mirrors what {@code PulsarClientImpl} does: the IdP policy is folded into the client's own
+     * factory rather than the plugin being left to self-provision a standalone one. Folding is the better of
+     * the two remedies because the standalone factory is built from a fresh
+     * {@code ClientConfigurationData} — losing the admin's SOCKS5 proxy scope and address, so IdP traffic
+     * stopped being proxied — and because it duplicates a {@code FileBasedTlsFactory} and its refresh
+     * scheduler per admin.
+     *
+     * <p>Called before the framework HTTP client factory is bound, so that factory has something to resolve
+     * {@code CLIENT_OAUTH2} against. A policy the caller supplied explicitly wins: the fold is
+     * {@code putIfAbsent}.
+     *
+     * @param conf the admin client configuration, mutated to carry the IdP policy
+     */
+    private void foldOAuth2IdpPolicy(ClientConfigurationData conf) {
+        if (!(auth instanceof AuthenticationOAuth2 oauth2)) {
+            return;
+        }
+        oauth2.idpTlsPolicy().ifPresent(policy -> {
+            Map<TlsPurpose, TlsPolicy> policies = conf.getTlsPolicyMap();
+            if (policies == null) {
+                policies = new LinkedHashMap<>();
+                conf.setTlsPolicyMap(policies);
+            }
+            policies.putIfAbsent(TlsPurpose.CLIENT_OAUTH2, policy);
+        });
+    }
+
+    /**
+     * The TLS factory the authentication plugin's framework HTTP client resolves purposes against: the one
+     * adopted onto the configuration when present, else the one this admin's {@link AsyncHttpConnector}
+     * composed. Returning {@code null} leaves the framework client on platform-default trust, which is correct
+     * only when nothing configured a trust domain for it.
+     *
+     * @param conf this admin's client configuration
+     * @return the TLS factory, or {@code null} when none was composed
+     */
+    private PulsarTlsFactory authTlsFactory(ClientConfigurationData conf) {
+        PulsarTlsFactory adopted = conf.getTlsFactory();
+        if (adopted != null) {
+            return adopted;
+        }
+        // From the provider rather than a connector: this is called during auth start() as well as per
+        // request, and at start() time no connector exists yet. The provider resolves lazily and memoizes,
+        // so asking it here composes the one factory this admin uses rather than an extra one.
+        return asyncConnectorProvider.tlsFactory();
+    }
+
+    /**
+     * Whether {@link #bindAuthenticationServices} actually bound the framework HTTP client factory into the
+     * authentication plugin.
+     *
+     * <p>Exists so a test can observe the decision at its real call site rather than by calling the
+     * predicate directly: review demonstrated that deleting the guard's invocation here left every test
+     * asserting {@code leaveOAuth2Standalone(...)} green, because none of them ran this method
+     * (VisibleForTesting).
+     *
+     * @return whether the framework HTTP client factory was bound
+     */
+    boolean boundAuthHttpClientFactoryForTest() {
+        return authHttpClientFactory != null;
+    }
+
+    /**
+     * The bound framework HTTP client factory, so a test can issue the IdP request through exactly the client
+     * the authentication plugin uses (VisibleForTesting).
+     *
+     * @return the framework HTTP client factory, or {@code null} when none was bound
+     */
+    FrameworkHttpClientFactory authHttpClientFactoryForTest() {
+        return authHttpClientFactory;
+    }
+
+    private void bindAuthenticationServices(ClientConfigurationData conf) {
+        if (conf == null || !(auth instanceof ClientAuthenticationServicesAware aware)) {
+            return;
+        }
+        foldOAuth2IdpPolicy(conf);
+        // Resolve the shared factory here, not on first use. First use is the OAuth2 plugin's start(), which
+        // runs inside the flow's own lock and blocks on material loading; resolving under that lock invites
+        // the loading path to deadlock against it. Doing it here — after the fold, so CLIENT_OAUTH2 is in the
+        // policy map, and before anything plugin-owned is locked — also surfaces a bad TLS configuration at
+        // PulsarAdmin build time rather than at the first authenticated request. No-op when nothing needs it.
+        asyncConnectorProvider.tlsFactory();
+        String clientInstanceId = "pulsar-admin-" + Integer.toHexString(System.identityHashCode(this));
+        // The TLS factory the framework HTTP client resolves its purposes against — notably the CLIENT_OAUTH2
+        // policy folded in just above. An adopted factory (the broker's admin-client attach) is already on the
+        // configuration; otherwise the one the connector composes is the admin's single factory, and reading it
+        // from there rather than composing a second one is what keeps the fold meaningful. The supplier is
+        // resolved lazily, at the first authenticated request, because the connector is created later in this
+        // constructor.
+        this.authHttpClientFactory = new FrameworkHttpClientFactory(
+                () -> null, () -> null, () -> null, () -> authTlsFactory(conf), conf, clientInstanceId);
+        OpenTelemetry openTelemetry = conf.getOpenTelemetry() != null ? conf.getOpenTelemetry()
+                : OpenTelemetry.noop();
+        // The admin OAuth2 flow only uses the HTTP client factory; the scheduler / blocking executor of the
+        // (binary-protocol) auth services are unused on the HTTP-only admin path.
+        ClientAuthenticationServices services = new DefaultClientAuthenticationServices(
+                authHttpClientFactory, null, null, Clock.systemDefaultZone(), openTelemetry, clientInstanceId);
+        aware.bindClientAuthenticationServices(services);
+    }
+
     @Override
     public void close() {
         try {
@@ -452,6 +600,14 @@ public class PulsarAdminImpl implements PulsarAdmin {
         client.close();
 
         asyncHttpConnector.close();
+        if (authHttpClientFactory != null) {
+            authHttpClientFactory.close();
+        }
+        // PIP-478: last, because everything above holds subscriptions on the factory this closes — the
+        // connectors' CLIENT_DEFAULT subscription and the auth HTTP clients' CLIENT_OAUTH2 one. Closing the
+        // factory first would tear it down while those subscriptions are still live, which a custom factory
+        // is entitled to treat as an error.
+        asyncConnectorProvider.close();
     }
 
     @VisibleForTesting
diff --git a/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/http/AsyncHttpConnector.java b/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/http/AsyncHttpConnector.java
index 8f30fad66a3b..eb7ecce939fa 100644
--- a/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/http/AsyncHttpConnector.java
+++ b/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/http/AsyncHttpConnector.java
@@ -29,10 +29,12 @@ import static org.asynchttpclient.util.HttpConstants.ResponseStatusCodes.TEMPORA
 import static org.asynchttpclient.util.MiscUtils.isNonEmpty;
 import com.google.common.annotations.VisibleForTesting;
 import com.spotify.futures.ConcurrencyReducer;
+import io.netty.buffer.ByteBufAllocator;
 import io.netty.channel.EventLoopGroup;
 import io.netty.handler.codec.http.DefaultHttpHeaders;
 import io.netty.handler.codec.http.HttpRequest;
 import io.netty.handler.codec.http.HttpResponse;
+import io.netty.handler.ssl.SslContext;
 import io.netty.resolver.NameResolver;
 import io.netty.util.concurrent.DefaultThreadFactory;
 import jakarta.ws.rs.ProcessingException;
@@ -54,9 +56,9 @@ import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Executors;
 import java.util.concurrent.Future;
 import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 import java.util.function.Supplier;
+import javax.net.ssl.SSLEngine;
 import lombok.CustomLog;
 import lombok.Data;
 import lombok.Getter;
@@ -66,18 +68,20 @@ import org.apache.commons.lang3.StringUtils;
 import org.apache.commons.lang3.Validate;
 import org.apache.pulsar.PulsarVersion;
 import org.apache.pulsar.client.admin.internal.PulsarAdminImpl;
-import org.apache.pulsar.client.api.PulsarClientException;
 import org.apache.pulsar.client.impl.PulsarClientSharedResourcesImpl;
 import org.apache.pulsar.client.impl.PulsarServiceNameResolver;
 import org.apache.pulsar.client.impl.ServiceNameResolver;
 import org.apache.pulsar.client.impl.conf.ClientConfigurationData;
+import org.apache.pulsar.client.impl.tls.ClientTlsFactorySupport;
 import org.apache.pulsar.client.util.ExecutorProvider;
-import org.apache.pulsar.client.util.PulsarHttpAsyncSslEngineFactory;
+import org.apache.pulsar.common.tls.impl.TlsContextAcquisition;
+import org.apache.pulsar.common.tls.impl.TlsSynthesisSpec;
 import org.apache.pulsar.common.util.FutureUtil;
-import org.apache.pulsar.common.util.PulsarSslConfiguration;
-import org.apache.pulsar.common.util.PulsarSslFactory;
 import org.apache.pulsar.common.util.netty.DnsResolverUtil;
 import org.apache.pulsar.common.util.netty.EventLoopUtil;
+import org.apache.pulsar.tls.PulsarTlsFactory;
+import org.apache.pulsar.tls.TlsHandle;
+import org.apache.pulsar.tls.TlsPurpose;
 import org.asynchttpclient.AsyncCompletionHandlerBase;
 import org.asynchttpclient.AsyncHandler;
 import org.asynchttpclient.AsyncHttpClient;
@@ -115,33 +119,60 @@ public class AsyncHttpConnector implements Connector, AsyncHttpRequestExecutor {
     private final ServiceNameResolver serviceNameResolver;
     private final ScheduledExecutorService delayer = Executors.newScheduledThreadPool(1,
             new DefaultThreadFactory("delayer"));
-    private ScheduledExecutorService sslRefresher;
     private final boolean acceptGzipCompression;
     @Getter
     private final NameResolver<InetAddress> nameResolver;
     private final EventLoopGroup eventLoopGroup;
     private final boolean createdEventLoopGroup;
     private final Map<String, ConcurrencyReducer<Response>> concurrencyReducers = new ConcurrentHashMap<>();
-    private PulsarSslFactory sslFactory;
+    // PIP-478 (new TLS path): the PulsarTlsFactory this connector reads, carried with the responsibility for
+    // closing it — owned when this connector resolved it, borrowed when AsyncHttpConnectorProvider supplied
+    // the one it shares across a PulsarAdmin's connectors. The subscription to the CLIENT_DEFAULT SslContext
+    // (whose callback refreshes the volatile below on rotation) is this connector's own either way.
+    private TlsFactoryOwnership tlsFactoryOwnership = TlsFactoryOwnership.none();
+    private TlsHandle<SslContext> tlsFactorySubscription;
+    private volatile SslContext tlsFactorySslContext;
+    private final TlsFactoryOwnership suppliedTlsFactory;
     @Getter
     @Setter
     private boolean followRedirects = true;
 
     public AsyncHttpConnector(Client client, ClientConfigurationData conf, int autoCertRefreshTimeSeconds,
                               boolean acceptGzipCompression) {
+        this(client, conf, autoCertRefreshTimeSeconds, acceptGzipCompression, TlsFactoryOwnership.none());
+    }
+
+    AsyncHttpConnector(Client client, ClientConfigurationData conf, int autoCertRefreshTimeSeconds,
+                       boolean acceptGzipCompression, TlsFactoryOwnership suppliedTlsFactory) {
         this((int) client.getConfiguration().getProperty(ClientProperties.CONNECT_TIMEOUT),
                 (int) client.getConfiguration().getProperty(ClientProperties.READ_TIMEOUT),
                 PulsarAdminImpl.DEFAULT_REQUEST_TIMEOUT_SECONDS * 1000,
                 autoCertRefreshTimeSeconds,
-                conf, acceptGzipCompression, null);
+                conf, acceptGzipCompression, null, suppliedTlsFactory);
     }
 
-    @SneakyThrows
     public AsyncHttpConnector(int connectTimeoutMs, int readTimeoutMs,
                               int requestTimeoutMs,
                               int autoCertRefreshTimeSeconds, ClientConfigurationData conf,
                               boolean acceptGzipCompression,
                               PulsarClientSharedResourcesImpl sharedResources) {
+        this(connectTimeoutMs, readTimeoutMs, requestTimeoutMs, autoCertRefreshTimeSeconds, conf,
+                acceptGzipCompression, sharedResources, TlsFactoryOwnership.none());
+    }
+
+    /**
+     * @param suppliedTlsFactory a factory somebody else owns, to borrow instead of resolving one — normally
+     *                           {@link AsyncHttpConnectorProvider}'s per-admin factory. Pass
+     *                           {@link TlsFactoryOwnership#none()} to resolve, and own, one from {@code conf}
+     */
+    @SneakyThrows
+    AsyncHttpConnector(int connectTimeoutMs, int readTimeoutMs,
+                       int requestTimeoutMs,
+                       int autoCertRefreshTimeSeconds, ClientConfigurationData conf,
+                       boolean acceptGzipCompression,
+                       PulsarClientSharedResourcesImpl sharedResources,
+                       TlsFactoryOwnership suppliedTlsFactory) {
+        this.suppliedTlsFactory = suppliedTlsFactory;
         Validate.notEmpty(conf.getServiceUrl(), "Service URL is not provided");
         serviceNameResolver = new PulsarServiceNameResolver();
         String serviceUrl = conf.getServiceUrl();
@@ -152,14 +183,39 @@ public class AsyncHttpConnector implements Connector, AsyncHttpRequestExecutor {
         this.nameResolver = sharedResourceHolder.getNameResolver();
         this.eventLoopGroup = sharedResourceHolder.getEventLoopGroup();
         this.createdEventLoopGroup = sharedResourceHolder.isCreateEventLoop();
-        AsyncHttpClientConfig asyncHttpClientConfig =
-                createAsyncHttpClientConfig(conf, connectTimeoutMs, readTimeoutMs, requestTimeoutMs,
-                        autoCertRefreshTimeSeconds, sharedResources);
-        httpClient = createAsyncHttpClient(asyncHttpClientConfig);
+        boolean initialized = false;
+        try {
+            AsyncHttpClientConfig asyncHttpClientConfig =
+                    createAsyncHttpClientConfig(conf, connectTimeoutMs, readTimeoutMs, requestTimeoutMs,
+                            autoCertRefreshTimeSeconds, sharedResources);
+            httpClient = createAsyncHttpClient(asyncHttpClientConfig);
+            initialized = true;
+        } finally {
+            if (!initialized) {
+                // Ctor-throw cleanup: createAsyncHttpClientConfig may already have allocated the TLS
+                // factory and its NON-daemon rotation executor (and, if we created it, the event loop group);
+                // release them here since close() will not run on a half-constructed connector — otherwise the
+                // non-daemon executor keeps the JVM alive.
+                releasePartialResources();
+            }
+        }
         this.requestTimeout = requestTimeoutMs > 0 ? Duration.ofMillis(requestTimeoutMs) : null;
         this.maxRetries = httpClient.getConfig().getMaxRequestRetry();
     }
 
+    /** Release resources that may have been allocated before a constructor failure. */
+    private void releasePartialResources() {
+        if (tlsFactorySubscription != null) {
+            tlsFactorySubscription.dispose();
+        }
+        // Same ownership rule as close(): a borrowed factory belongs to the provider, which closes it even
+        // when this connector's construction failed.
+        tlsFactoryOwnership.close();
+        if (createdEventLoopGroup && eventLoopGroup != null && !eventLoopGroup.isShutdown()) {
+            eventLoopGroup.shutdownGracefully();
+        }
+    }
+
     private SharedResourceHolder buildResourcesIfConfigured(
             PulsarClientSharedResourcesImpl sharedResources) {
         EventLoopGroup eventLoopGroup = null;
@@ -194,7 +250,12 @@ public class AsyncHttpConnector implements Connector, AsyncHttpRequestExecutor {
         configureAsyncHttpClientConfig(conf, connectTimeoutMs,
                 readTimeoutMs, requestTimeoutMs, confBuilder, sharedResources);
         if (conf.getServiceUrl().startsWith("https://")) {
-            configureAsyncHttpClientSslEngineFactory(conf, autoCertRefreshTimeSeconds, confBuilder);
+            configureAsyncHttpClientWithTlsFactory(confBuilder, resolveNewTlsFactory(conf), conf);
+        } else if (needsTlsFactory(conf)) {
+            // Only the factory is resolved here: no SslEngineFactory is wired onto this connector, so the
+            // admin transport itself stays plaintext (the same separation as
+            // PulsarClientImpl.needsClientTlsFactory).
+            resolveNewTlsFactory(conf);
         }
         AsyncHttpClientConfig asyncHttpClientConfig = confBuilder.build();
         return asyncHttpClientConfig;
@@ -243,8 +304,10 @@ public class AsyncHttpConnector implements Connector, AsyncHttpRequestExecutor {
                         && super.keepAlive(remoteAddress, ahcRequest, request, response);
             }
         });
-        confBuilder.setSslEngineFactory(
-                new PulsarHttpAsyncSslEngineFactory(sslFactory, null, conf.isTlsHostnameVerificationEnable()));
+        // This flag only governs AsyncHttpClient's default engine factory; the https path installs its own
+        // SslEngineFactory with hostname verification baked into the factory-built Netty context (see
+        // configureAsyncHttpClientWithTlsFactory).
+        confBuilder.setDisableHttpsEndpointIdentificationAlgorithm(!conf.isTlsHostnameVerificationEnable());
         configureSocks5ProxyIfNeeded(confBuilder, conf);
     }
 
@@ -252,30 +315,114 @@ public class AsyncHttpConnector implements Connector, AsyncHttpRequestExecutor {
         return new DefaultAsyncHttpClient(asyncHttpClientConfig);
     }
 
+    /**
+     * Resolve the new-SPI TLS factory for an https admin URL (the only path since the PIP-337 removal):
+     * a {@link PulsarTlsFactory} adopted through the {@code tlsFactory} seam (the broker's admin-client
+     * attach — see {@code PulsarService.getCreateAdminClientBuilder}), or the built-in file-based factory
+     * composed from the {@code tls*} fields. A connector that resolves the factory itself owns it (and its
+     * rotation executor) and releases both in {@link #close()}; one that borrows the provider's shared factory
+     * does not. {@link TlsFactoryOwnership} carries which of the two applies.
+     */
+    /**
+     * Whether this admin configuration needs a {@link PulsarTlsFactory} at all: the admin transport is HTTPS,
+     * or the configuration describes a trust domain other than the transport — the {@code CLIENT_OAUTH2}
+     * policy {@code PulsarAdminImpl} folds in from an OAuth2 plugin's IdP material — or a factory was adopted
+     * onto the configuration and still has to be initialized and owned. Without the factory in that
+     * second case the admin's framework HTTP client has nothing to resolve {@code CLIENT_OAUTH2} against and
+     * falls back to platform-default trust, silently ignoring the plugin's {@code trustCertsFilePath}.
+     *
+     * <p>Shared with {@link AsyncHttpConnectorProvider}, which resolves the one factory per admin, so the
+     * decision to allocate and the decision to use cannot drift apart.
+     *
+     * @param conf the admin client configuration
+     * @return whether a TLS factory must be resolved
+     */
+    static boolean needsTlsFactory(ClientConfigurationData conf) {
+        return conf.getServiceUrl() != null && conf.getServiceUrl().startsWith("https://")
+                || conf.getTlsPolicyMap() != null && !conf.getTlsPolicyMap().isEmpty()
+                // An adopted factory counts too: resolution is what initializes it and puts it under the
+                // provider's ownership, so skipping it here would serve the framework HTTP client an
+                // uninitialized factory that nothing ever closes. In-tree the broker's admin attach only
+                // adopts on an https URL, so this arm is reachable only from out-of-tree code — but the
+                // rule pip-478.md states covers adoption, and this is what makes the code match it.
+                || conf.getTlsFactory() != null;
+    }
+
     @SneakyThrows
-    private void configureAsyncHttpClientSslEngineFactory(ClientConfigurationData conf, int autoCertRefreshTimeSeconds,
-                                                          DefaultAsyncHttpClientConfig.Builder confBuilder)
-            throws GeneralSecurityException, IOException {
-        // Set client key and certificate if available
-        sslRefresher = Executors.newScheduledThreadPool(1,
-                new DefaultThreadFactory("pulsar-admin-ssl-refresher"));
-        PulsarSslConfiguration sslConfiguration = buildSslConfiguration(conf, serviceNameResolver
-                .resolveHostUri().getHost());
-        this.sslFactory = (PulsarSslFactory) Class.forName(conf.getSslFactoryPlugin())
-                .getConstructor().newInstance();
-        this.sslFactory.initialize(sslConfiguration);
-        this.sslFactory.createInternalSslContext();
-        if (conf.getAutoCertRefreshSeconds() > 0) {
-            this.sslRefresher.scheduleWithFixedDelay(this::refreshSslContext, conf.getAutoCertRefreshSeconds(),
-                    conf.getAutoCertRefreshSeconds(), TimeUnit.SECONDS);
+    private PulsarTlsFactory resolveNewTlsFactory(ClientConfigurationData conf) {
+        if (suppliedTlsFactory.isPresent()) {
+            // A sibling connector on the same PulsarAdmin already resolved it. Resolving again would construct
+            // and initialize a second instance of a by-name custom factory — the SPI says initialize is called
+            // once — and, for a factory adopted from the broker's admin attach, would close the same instance
+            // twice. This connector still takes its own subscription below; only the factory is shared.
+            this.tlsFactoryOwnership = suppliedTlsFactory;
+            return suppliedTlsFactory.factory();
         }
-        String hostname = conf.isTlsHostnameVerificationEnable() ? null : serviceNameResolver
-                .resolveHostUri().getHost();
-        SslEngineFactory sslEngineFactory =
-                new PulsarHttpAsyncSslEngineFactory(sslFactory, hostname, conf.isTlsHostnameVerificationEnable());
-        confBuilder.setSslEngineFactory(sslEngineFactory);
-        confBuilder.setUseInsecureTrustManager(conf.isTlsAllowInsecureConnection());
-        confBuilder.setDisableHttpsEndpointIdentificationAlgorithm(!conf.isTlsHostnameVerificationEnable());
+        // The factory needs a framework scheduler for material-rotation polling and an executor for blocking
+        // material loading; a connector that resolves its own owns a small single-thread scheduled executor for
+        // both (there is no shared client scheduler here, unlike the PulsarClientImpl funnel).
+        ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(
+                new DefaultThreadFactory("pulsar-admin-tls-factory"));
+        PulsarTlsFactory factory = ClientTlsFactorySupport.resolveClientTlsFactory(conf, executor,
+                executor, conf.getOpenTelemetry());
+        this.tlsFactoryOwnership = TlsFactoryOwnership.owning(factory, executor);
+        return factory;
+    }
+
+    /**
+     * New PIP-478 HTTPS TLS setup: subscribe once to the {@link TlsPurpose#CLIENT_DEFAULT} Netty
+     * {@code SslContext} — the callback refreshes a volatile on rotation — and back the AsyncHttpClient
+     * {@link SslEngineFactory} with it, mirroring {@code org.apache.pulsar.client.impl.HttpClient} and the
+     * framework {@code FrameworkHttpClientFactory}. Each connection's engine is built from the most recently
+     * delivered (possibly rotated) context; hostname verification, insecure trust, ciphers and protocols are
+     * baked into the factory-built context per the {@code TlsPolicy}, so the AsyncHttpClient
+     * endpoint-identification / insecure-trust-manager flags are not applied on this path.
+     *
+     * <p>Acquisition goes through {@link TlsContextAcquisition} so a custom factory that supplies only the
+     * JDK {@code SSLContext} is served a framework-synthesized Netty context carrying the client's
+     * hostname-verification setting.
+     */
+    @SneakyThrows
+    private void configureAsyncHttpClientWithTlsFactory(DefaultAsyncHttpClientConfig.Builder confBuilder,
+                                                        PulsarTlsFactory factory, ClientConfigurationData conf) {
+        this.tlsFactorySubscription = TlsContextAcquisition.acquireNettyContext(factory, TlsPurpose.CLIENT_DEFAULT,
+                        TlsSynthesisSpec.client(conf.isTlsHostnameVerificationEnable()),
+                        ctx -> this.tlsFactorySslContext = ctx)
+                .get()
+                .orElseThrow(() -> new IllegalStateException(
+                        "Admin TLS factory supplied no Netty SslContext for purpose " + TlsPurpose.CLIENT_DEFAULT));
+        confBuilder.setSslEngineFactory(new SslEngineFactory() {
+            @Override
+            public SSLEngine newSslEngine(AsyncHttpClientConfig ahcConfig, String peerHost, int peerPort) {
+                // Client mode, SNI and baked-in hostname verification all come from the Netty context. Pin the
+                // context across newEngine so a concurrent rotation cannot free the native OpenSSL context
+                // mid-build (use-after-free guard).
+                return TlsContextAcquisition.withPinnedContext(() -> tlsFactorySslContext,
+                        ctx -> ctx.newEngine(ByteBufAllocator.DEFAULT, peerHost, peerPort));
+            }
+        });
+        // Bound how long an established pooled HTTPS connection keeps pre-rotation material: new
+        // connections pick up rotation via the SslEngineFactory above, but a pooled connection would otherwise
+        // hold pre-rotation trust/cert indefinitely. Aligns with FrameworkHttpClientFactory.
+        confBuilder.setConnectionTtl(Duration.ofMillis(TlsContextAcquisition.httpTlsRotationConnectionTtlMillis()));
+    }
+
+    /**
+     * @return the resolved PIP-478 TLS factory when this connector is on the new TLS path, or {@code null} on
+     *         the legacy PIP-337 path
+     */
+    @VisibleForTesting
+    public PulsarTlsFactory getTlsFactory() {
+        return tlsFactoryOwnership.factory();
+    }
+
+    /**
+     * @return the AsyncHttpClient connection-TTL (millis) bounding how long a pooled HTTPS connection keeps
+     *         pre-rotation TLS material on the new factory path (negative when unset, i.e. the plaintext path)
+     */
+    @VisibleForTesting
+    public int getConnectionTtlMs() {
+        return (int) httpClient.getConfig().getConnectionTtl().toMillis();
     }
 
     @Override
@@ -622,9 +769,14 @@ public class AsyncHttpConnector implements Connector, AsyncHttpRequestExecutor {
         try {
             httpClient.close();
             delayer.shutdownNow();
-            if (sslRefresher != null) {
-                sslRefresher.shutdownNow();
+            // PIP-478 (new TLS path): release the CLIENT_DEFAULT subscription, close the factory and
+            // stop its rotation executor (all no-ops when the admin URL is not https).
+            if (tlsFactorySubscription != null) {
+                tlsFactorySubscription.dispose();
             }
+            // The subscription above is this connector's own either way; the handle knows whether the factory
+            // and its executor are ours to release, so this is a no-op when they are borrowed.
+            tlsFactoryOwnership.close();
             if (createdEventLoopGroup && eventLoopGroup != null && !eventLoopGroup.isShutdown()) {
                 eventLoopGroup.shutdownGracefully();
             }
@@ -633,39 +785,6 @@ public class AsyncHttpConnector implements Connector, AsyncHttpRequestExecutor {
         }
     }
 
-    protected PulsarSslConfiguration buildSslConfiguration(ClientConfigurationData conf, String host)
-            throws PulsarClientException {
-        return PulsarSslConfiguration.builder()
-                .tlsProvider(conf.getSslProvider())
-                .tlsKeyStoreType(conf.getTlsKeyStoreType())
-                .tlsKeyStorePath(conf.getTlsKeyStorePath())
-                .tlsKeyStorePassword(conf.getTlsKeyStorePassword())
-                .tlsTrustStoreType(conf.getTlsTrustStoreType())
-                .tlsTrustStorePath(conf.getTlsTrustStorePath())
-                .tlsTrustStorePassword(conf.getTlsTrustStorePassword())
-                .tlsCiphers(conf.getTlsCiphers())
-                .tlsProtocols(conf.getTlsProtocols())
-                .tlsTrustCertsFilePath(conf.getTlsTrustCertsFilePath())
-                .tlsCertificateFilePath(conf.getTlsCertificateFilePath())
-                .tlsKeyFilePath(conf.getTlsKeyFilePath())
-                .allowInsecureConnection(conf.isTlsAllowInsecureConnection())
-                .requireTrustedClientCertOnConnect(false)
-                .tlsEnabledWithKeystore(conf.isUseKeyStoreTls())
-                .authData(conf.getAuthentication().getAuthData(host))
-                .tlsCustomParams(conf.getSslFactoryPluginParams())
-                .serverMode(false)
-                .isHttps(true)
-                .build();
-    }
-
-    protected void refreshSslContext() {
-        try {
-            this.sslFactory.update();
-        } catch (Exception e) {
-            log.error().exception(e).log("Failed to refresh SSL context");
-        }
-    }
-
     @Data
     private static class SharedResourceHolder {
         static final SharedResourceHolder EMPTY = new SharedResourceHolder(null, null, false);
diff --git a/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/http/AsyncHttpConnectorProvider.java b/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/http/AsyncHttpConnectorProvider.java
index bf5efe3ffd6d..ba31638d1f90 100644
--- a/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/http/AsyncHttpConnectorProvider.java
+++ b/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/http/AsyncHttpConnectorProvider.java
@@ -19,10 +19,15 @@
 package org.apache.pulsar.client.admin.internal.http;
 
 import com.google.common.annotations.VisibleForTesting;
+import io.netty.util.concurrent.DefaultThreadFactory;
 import jakarta.ws.rs.client.Client;
 import jakarta.ws.rs.core.Configuration;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
 import org.apache.pulsar.client.impl.PulsarClientSharedResourcesImpl;
 import org.apache.pulsar.client.impl.conf.ClientConfigurationData;
+import org.apache.pulsar.client.impl.tls.ClientTlsFactorySupport;
+import org.apache.pulsar.tls.PulsarTlsFactory;
 import org.glassfish.jersey.client.spi.Connector;
 import org.glassfish.jersey.client.spi.ConnectorProvider;
 
@@ -36,6 +41,14 @@ public class AsyncHttpConnectorProvider implements ConnectorProvider {
     private final int autoCertRefreshTimeSeconds;
     private final boolean acceptGzipCompression;
     private boolean followRedirects = true;
+    // PIP-478: one TLS factory per PulsarAdmin. A PulsarAdmin ends up with two connectors — the one Jersey
+    // creates lazily on the first request, and the one PulsarAdminImpl builds directly — and each used to
+    // resolve its own. For a by-name custom factory that meant constructing and initializing it twice, against
+    // the SPI's "initialize is called once"; for a factory adopted from the broker's admin attach it meant
+    // closing the same instance twice. The provider is the natural owner: it is per-admin and creates both
+    // connectors. Resolved lazily, on the first connector that needs TLS, so a plaintext admin with nothing to
+    // serve still allocates nothing.
+    private TlsFactoryOwnership sharedTlsFactory;
 
     public AsyncHttpConnectorProvider(ClientConfigurationData conf, int autoCertRefreshTimeSeconds,
                                       boolean acceptGzipCompression) {
@@ -47,7 +60,8 @@ public class AsyncHttpConnectorProvider implements ConnectorProvider {
     @Override
     public Connector getConnector(Client client, Configuration runtimeConfig) {
         if (connector == null) {
-            connector = new AsyncHttpConnector(client, conf, autoCertRefreshTimeSeconds, acceptGzipCompression);
+            connector = new AsyncHttpConnector(client, conf, autoCertRefreshTimeSeconds, acceptGzipCompression,
+                    sharedTlsFactory());
             connector.setFollowRedirects(followRedirects);
         }
         return connector;
@@ -57,7 +71,65 @@ public class AsyncHttpConnectorProvider implements ConnectorProvider {
     public AsyncHttpConnector getConnector(int connectTimeoutMs, int readTimeoutMs, int requestTimeoutMs,
             int autoCertRefreshTimeSeconds, PulsarClientSharedResourcesImpl sharedResources) {
         return new AsyncHttpConnector(connectTimeoutMs, readTimeoutMs, requestTimeoutMs, autoCertRefreshTimeSeconds,
-                conf, acceptGzipCompression, sharedResources);
+                conf, acceptGzipCompression, sharedResources, sharedTlsFactory());
+    }
+
+    /**
+     * The single {@link PulsarTlsFactory} shared by every connector this provider creates, or {@code null}
+     * when this admin configuration needs none. Resolved at most once; the connectors take their own
+     * subscriptions from it but never close it.
+     *
+     * @return the shared TLS factory, or {@code null} when none is needed
+     */
+    /**
+     * The TLS factory this provider owns and shares with its connectors, resolved if it has not been yet.
+     *
+     * <p>Exposed because {@code PulsarAdminImpl} needs it before any connector exists: an OAuth2 plugin's
+     * {@code start()} fetches IdP metadata through the framework HTTP client, which resolves
+     * {@code CLIENT_OAUTH2} against this factory, and that happens while the admin is still being
+     * constructed.
+     *
+     * @return the shared factory, or {@code null} when this configuration needs none
+     */
+    public PulsarTlsFactory tlsFactory() {
+        return sharedTlsFactory().factory();
+    }
+
+    @VisibleForTesting
+    synchronized TlsFactoryOwnership sharedTlsFactory() {
+        if (sharedTlsFactory != null) {
+            // Already resolved. The connectors borrow it: this provider stays the owner.
+            return TlsFactoryOwnership.borrowing(sharedTlsFactory.factory());
+        }
+        if (!AsyncHttpConnector.needsTlsFactory(conf)) {
+            sharedTlsFactory = TlsFactoryOwnership.none();
+            return sharedTlsFactory;
+        }
+        ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(
+                new DefaultThreadFactory("pulsar-admin-tls-factory"));
+        try {
+            sharedTlsFactory = TlsFactoryOwnership.owning(
+                    ClientTlsFactorySupport.resolveClientTlsFactory(conf, executor, executor,
+                            conf.getOpenTelemetry()),
+                    executor);
+        } catch (Exception e) {
+            executor.shutdownNow();
+            throw new RuntimeException("Failed to resolve the admin client TLS factory", e);
+        }
+        return TlsFactoryOwnership.borrowing(sharedTlsFactory.factory());
+    }
+
+    /**
+     * Release the shared TLS factory and the executor driving its rotation. Called when the owning
+     * {@code PulsarAdmin} closes; the connectors borrowed the factory and dispose only their own
+     * subscriptions.
+     */
+    public synchronized void close() {
+        if (sharedTlsFactory == null) {
+            return;
+        }
+        sharedTlsFactory.close();
+        sharedTlsFactory = TlsFactoryOwnership.none();
     }
 
     @VisibleForTesting
diff --git a/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/http/TlsFactoryOwnership.java b/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/http/TlsFactoryOwnership.java
new file mode 100644
index 000000000000..50f43beb57ed
--- /dev/null
+++ b/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/http/TlsFactoryOwnership.java
@@ -0,0 +1,114 @@
+/*
+ * 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.pulsar.client.admin.internal.http;
+
+import java.util.concurrent.ScheduledExecutorService;
+import lombok.CustomLog;
+import org.apache.pulsar.tls.PulsarTlsFactory;
+
+/**
+ * A {@link PulsarTlsFactory} together with the responsibility for closing it (PIP-478).
+ *
+ * <p>The admin client has more than one holder of the same factory: a {@code PulsarAdmin} ends up with two
+ * {@link AsyncHttpConnector} instances — the one {@code PulsarAdminImpl} builds and the one Jersey creates
+ * lazily on the first request — and the factory may in turn have been handed in from outside, by the broker's
+ * admin attach. Every one of those holders needs the factory; exactly one may close it. Carrying the
+ * factory and that responsibility together makes the handoff say which one it is, so a holder cannot acquire
+ * a reference without also learning whether closing is its job.
+ *
+ * <p>{@link #close()} is therefore always safe to call: it releases the factory and its rotation executor
+ * when this handle {@linkplain #owning owns} them, and does nothing when it is only {@linkplain #borrowing
+ * borrowing} them.
+ */
+@CustomLog
+final class TlsFactoryOwnership implements AutoCloseable {
+
+    private static final TlsFactoryOwnership NONE = new TlsFactoryOwnership(null, null, false);
+
+    private final PulsarTlsFactory factory;
+    private final ScheduledExecutorService executor;
+    private final boolean owned;
+
+    private TlsFactoryOwnership(PulsarTlsFactory factory, ScheduledExecutorService executor, boolean owned) {
+        this.factory = factory;
+        this.executor = executor;
+        this.owned = owned;
+    }
+
+    /**
+     * @return a handle on no factory at all — this configuration needs none
+     */
+    static TlsFactoryOwnership none() {
+        return NONE;
+    }
+
+    /**
+     * Take responsibility for a factory this holder resolved itself, and for the executor driving its
+     * material rotation. {@link #close()} releases both.
+     *
+     * @param factory  the resolved factory
+     * @param executor the executor driving its rotation, released with it
+     * @return an owning handle
+     */
+    static TlsFactoryOwnership owning(PulsarTlsFactory factory, ScheduledExecutorService executor) {
+        return factory == null ? NONE : new TlsFactoryOwnership(factory, executor, true);
+    }
+
+    /**
+     * Use a factory somebody else owns and will close — here, the one
+     * {@link AsyncHttpConnectorProvider} shares across the connectors of a single {@code PulsarAdmin}.
+     * {@link #close()} does nothing.
+     *
+     * @param factory the borrowed factory, or {@code null} for {@link #none()}
+     * @return a borrowing handle
+     */
+    static TlsFactoryOwnership borrowing(PulsarTlsFactory factory) {
+        return factory == null ? NONE : new TlsFactoryOwnership(factory, null, false);
+    }
+
+    /**
+     * @return the factory, or {@code null} when there is none
+     */
+    PulsarTlsFactory factory() {
+        return factory;
+    }
+
+    /**
+     * @return whether this handle carries a factory
+     */
+    boolean isPresent() {
+        return factory != null;
+    }
+
+    @Override
+    public void close() {
+        if (!owned) {
+            return;
+        }
+        try {
+            factory.close();
+        } catch (Exception e) {
+            log.warn().exception(e).log("Failed to close the admin client TLS factory");
+        } finally {
+            if (executor != null) {
+                executor.shutdownNow();
+            }
+        }
+    }
+}
diff --git a/pulsar-client-admin/src/test/java/org/apache/pulsar/client/admin/internal/AdminOAuth2IdpTlsBindingTest.java b/pulsar-client-admin/src/test/java/org/apache/pulsar/client/admin/internal/AdminOAuth2IdpTlsBindingTest.java
new file mode 100644
index 000000000000..36834697ab3d
--- /dev/null
+++ b/pulsar-client-admin/src/test/java/org/apache/pulsar/client/admin/internal/AdminOAuth2IdpTlsBindingTest.java
@@ -0,0 +1,110 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pulsar.client.admin.internal;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import org.apache.pulsar.client.admin.PulsarAdmin;
+import org.apache.pulsar.client.admin.PulsarAdminBuilder;
+import org.apache.pulsar.client.impl.auth.oauth2.AuthenticationOAuth2;
+import org.apache.pulsar.tls.TlsPolicy;
+import org.apache.pulsar.tls.TlsPurpose;
+import org.testng.annotations.Test;
+
+/**
+ * PIP-478 / issue #24944: a {@code PulsarAdmin} using OAuth2 with its own IdP TLS material
+ * ({@code trustCertsFilePath} / {@code tlsCertFile} / {@code tlsKeyFile}) must keep honouring it.
+ *
+ * <p>The remedy mirrors {@code PulsarClientImpl}: fold the IdP policy into the admin's own TLS factory under
+ * {@link TlsPurpose#CLIENT_OAUTH2}, rather than leaving the plugin to self-provision a standalone factory.
+ * Folding preserves the admin's transport settings — notably its SOCKS5 proxy scope, which a fresh
+ * {@code ClientConfigurationData} would have reset to {@code BINARY_ONLY} — and avoids a duplicate factory
+ * and refresh scheduler per admin.
+ */
+public class AdminOAuth2IdpTlsBindingTest {
+
+    private static final String ISSUER = "{\"type\":\"client_credentials\","
+            + "\"issuerUrl\":\"https://idp.example.com\","
+            + "\"privateKey\":\"data:application/json;base64,e30=\","
+            + "\"audience\":\"test\"";
+
+    @Test
+    public void idpTrustMaterialIsFoldedIntoTheAdminFactory() throws Exception {
+        try (PulsarAdminImpl admin = adminWith(oauth2(ISSUER + ",\"trustCertsFilePath\":\"/tls/idp-ca.pem\"}"))) {
+            TlsPolicy idp = admin.getClientConfigData().getTlsPolicyMap().get(TlsPurpose.CLIENT_OAUTH2);
+            assertThat(idp).as("the IdP policy must reach the admin's own factory").isNotNull();
+            assertThat(idp.trustCertsFilePath()).isEqualTo("/tls/idp-ca.pem");
+        }
+    }
+
+    @Test
+    public void idpMtlsMaterialIsFoldedToo() throws Exception {
+        try (PulsarAdminImpl admin = adminWith(oauth2(ISSUER
+                + ",\"tlsCertFile\":\"/tls/idp-client.pem\",\"tlsKeyFile\":\"/tls/idp-client.key\"}"))) {
+            TlsPolicy idp = admin.getClientConfigData().getTlsPolicyMap().get(TlsPurpose.CLIENT_OAUTH2);
+            assertThat(idp).isNotNull();
+            assertThat(idp.certificateFilePath()).isEqualTo("/tls/idp-client.pem");
+            assertThat(idp.keyFilePath()).isEqualTo("/tls/idp-client.key");
+        }
+    }
+
+    @Test
+    public void anExplicitOauth2PolicyWins() throws Exception {
+        // The fold is putIfAbsent, so a policy the caller supplied explicitly must not be overwritten by the
+        // one derived from authParams.
+        TlsPolicy explicit = TlsPolicy.builder().trustCertsFilePath("/tls/explicit-ca.pem").build();
+        PulsarAdminBuilder builder = PulsarAdmin.builder()
+                .serviceHttpUrl("http://localhost:8080")
+                .authentication(oauth2(ISSUER + ",\"trustCertsFilePath\":\"/tls/idp-ca.pem\"}"));
+        ((PulsarAdminBuilderImpl) builder).getConf()
+                .setTlsPolicyMap(new java.util.LinkedHashMap<>(java.util.Map.of(TlsPurpose.CLIENT_OAUTH2, explicit)));
+        try (PulsarAdminImpl admin = (PulsarAdminImpl) builder.build()) {
+            assertThat(admin.getClientConfigData().getTlsPolicyMap().get(TlsPurpose.CLIENT_OAUTH2)
+                    .trustCertsFilePath()).isEqualTo("/tls/explicit-ca.pem");
+        }
+    }
+
+    @Test
+    public void noIdpMaterialAddsNoPolicy() throws Exception {
+        try (PulsarAdminImpl admin = adminWith(oauth2(ISSUER + "}"))) {
+            var map = admin.getClientConfigData().getTlsPolicyMap();
+            assertThat(map == null || map.get(TlsPurpose.CLIENT_OAUTH2) == null)
+                    .as("nothing to fold, so no CLIENT_OAUTH2 policy is invented").isTrue();
+        }
+    }
+
+    private static PulsarAdminImpl adminWith(AuthenticationOAuth2 auth) throws Exception {
+        return (PulsarAdminImpl) PulsarAdmin.builder()
+                .serviceHttpUrl("http://localhost:8080")
+                .authentication(auth)
+                .build();
+    }
+
+    /** start() is stubbed: it would fetch OAuth2 server metadata; the fold runs before it. */
+    private static AuthenticationOAuth2 oauth2(String params) {
+        AuthenticationOAuth2 auth = new AuthenticationOAuth2() {
+            private static final long serialVersionUID = 1L;
+
+            @Override
+            public void start() {
+            }
+        };
+        auth.configure(params);
+        return auth;
+    }
+}
diff --git a/pulsar-client-admin/src/test/java/org/apache/pulsar/client/admin/internal/AdminOAuth2IdpTlsEndToEndTest.java b/pulsar-client-admin/src/test/java/org/apache/pulsar/client/admin/internal/AdminOAuth2IdpTlsEndToEndTest.java
new file mode 100644
index 000000000000..40d7b3e572da
--- /dev/null
+++ b/pulsar-client-admin/src/test/java/org/apache/pulsar/client/admin/internal/AdminOAuth2IdpTlsEndToEndTest.java
@@ -0,0 +1,238 @@
+/*
+ * 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.pulsar.client.admin.internal;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.moreThanOrExactly;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import com.github.tomakehurst.wiremock.WireMockServer;
+import com.google.common.io.Resources;
+import java.io.File;
+import java.io.OutputStream;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.KeyStore;
+import java.security.PrivateKey;
+import java.security.cert.Certificate;
+import java.security.cert.X509Certificate;
+import java.time.Duration;
+import java.util.concurrent.TimeUnit;
+import org.apache.pulsar.client.admin.PulsarAdmin;
+import org.apache.pulsar.client.impl.auth.oauth2.AuthenticationOAuth2;
+import org.apache.pulsar.client.impl.auth.v5.FrameworkHttpClientFactory;
+import org.apache.pulsar.common.util.tls.PemReader;
+import org.apache.pulsar.http.HttpRequest;
+import org.apache.pulsar.http.HttpResponse;
+import org.apache.pulsar.http.PulsarHttpClient;
+import org.apache.pulsar.http.PulsarHttpClientConfig;
+import org.apache.pulsar.tls.TlsPurpose;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+/**
+ * PIP-478 / issue #24944, end to end: the admin counterpart of {@code OAuth2IdpTlsFrameworkClientTest}.
+ * {@link AdminOAuth2IdpTlsBindingTest} asserts the <em>binding decision</em> — that the IdP policy is folded
+ * into the admin's configuration under {@link TlsPurpose#CLIENT_OAUTH2}. That is necessary but not
+ * sufficient: the fold is only meaningful if the admin also has a TLS factory to resolve that purpose
+ * against, and if the framework HTTP client reads it. This test closes that gap by making a real HTTPS
+ * request to a WireMock IdP whose certificate is signed by the shared test CA — trusted only via the
+ * plugin's {@code trustCertsFilePath}.
+ *
+ * <p>The admin's own service URL is plaintext {@code http://} on purpose: that is where the gap lived. The
+ * connector composed a TLS factory only for an {@code https://} admin URL, so a plaintext admin folded the
+ * IdP policy into a configuration that no factory ever read, and the IdP connection silently fell back to
+ * platform-default trust.
+ */
+public class AdminOAuth2IdpTlsEndToEndTest {
+
+    // broker.cert.pem carries SAN:localhost and chains to ca.cert.pem, so both trust and hostname verification
+    // pass against the WireMock server (the shared broker.keystore.jks has no SAN).
+    private static final String BROKER_CERT = resource("certificate-authority/server-keys/broker.cert.pem");
+    private static final String BROKER_KEY = resource("certificate-authority/server-keys/broker.key-pk8.pem");
+    private static final String CA_CERT = resource("certificate-authority/certs/ca.cert.pem");
+    private static final String STORE_PW = "111111";
+
+    private WireMockServer idp;
+    private Path serverKeystore;
+
+    @BeforeMethod
+    public void setUp() throws Exception {
+        serverKeystore = buildServerKeystore();
+        idp = new WireMockServer(wireMockConfig().dynamicHttpsPort()
+                .keystorePath(serverKeystore.toString()).keystoreType("JKS")
+                .keyManagerPassword(STORE_PW).keystorePassword(STORE_PW));
+        idp.start();
+        idp.stubFor(get(urlEqualTo("/probe")).willReturn(aResponse().withStatus(200).withBody("ok")));
+        // Enough of an OIDC discovery document for ClientCredentialsFlow.initialize() to complete, so a test
+        // can exercise the real AuthenticationOAuth2.start() rather than stubbing it out.
+        idp.stubFor(get(urlEqualTo("/.well-known/openid-configuration")).willReturn(aResponse().withStatus(200)
+                .withHeader("Content-Type", "application/json")
+                .withBody("{\"issuer\":\"" + idp.baseUrl() + "\",\"token_endpoint\":\""
+                        + idp.baseUrl() + "/oauth/token\"}")));
+    }
+
+    @AfterMethod(alwaysRun = true)
+    public void tearDown() throws Exception {
+        if (idp != null) {
+            idp.stop();
+        }
+        if (serverKeystore != null) {
+            Files.deleteIfExists(serverKeystore);
+        }
+    }
+
+    @Test
+    public void theFoldedIdpTrustReachesTheIdpThroughTheAdminsFrameworkHttpClient() throws Exception {
+        try (PulsarAdminImpl admin = adminWith(oauth2WithTrust(CA_CERT))) {
+            HttpResponse response = probe(admin);
+            assertThat(response.statusCode())
+                    .as("the admin's framework HTTP client must reach the IdP using the folded CLIENT_OAUTH2 "
+                            + "trust; a null factory would leave it on platform-default trust")
+                    .isEqualTo(200);
+        }
+    }
+
+    @Test
+    public void withoutTheIdpTrustTheAdminRejectsTheIdpCertificate() throws Exception {
+        // The contrast that proves the folded policy — and not some ambient trust — is what carries the CA.
+        try (PulsarAdminImpl admin = adminWith(oauth2WithTrust(null))) {
+            assertThatThrownBy(() -> probe(admin))
+                    .as("platform-default trust must reject the test-CA-signed IdP certificate")
+                    .satisfies(t -> assertThat(causeChain(t)).containsAnyOf("SSL", "certificat", "PKIX",
+                            "unable to find valid certification path"));
+        }
+    }
+
+    @Test
+    public void theRealOauth2StartFetchesIdpMetadataOverTheFoldedTrust() throws Exception {
+        // The other two tests stub start(), so they exercise only the per-request path. start() is the
+        // earlier — and stricter — user of the factory: ClientCredentialsFlow.initialize() fetches the IdP
+        // discovery document through the framework HTTP client while the PulsarAdmin is still being
+        // constructed. That is before any AsyncHttpConnector exists, so the factory has to come from the
+        // provider; sourcing it from a connector left it null here and fell back to platform trust.
+        AuthenticationOAuth2 auth = new AuthenticationOAuth2();
+        auth.configure("{\"type\":\"client_credentials\",\"issuerUrl\":\"" + idp.baseUrl()
+                + "\",\"privateKey\":\"data:application/json;base64,e30=\",\"trustCertsFilePath\":\""
+                + CA_CERT + "\"}");
+
+        try (PulsarAdmin admin = PulsarAdmin.builder()
+                .serviceHttpUrl("http://localhost:8080")
+                .authentication(auth)
+                .build()) {
+            assertThat(admin).isNotNull();
+        }
+        // The count is not the point (the resolver may probe more than once) — reaching the IdP at all over
+        // HTTPS is, since the handshake only succeeds on the folded trust.
+        idp.verify(moreThanOrExactly(1), getRequestedFor(urlEqualTo("/.well-known/openid-configuration")));
+    }
+
+    @Test
+    public void withoutTheIdpTrustTheRealOauth2StartFailsToReachTheIdp() throws Exception {
+        // The contrast for the start() path: same flow, no trustCertsFilePath, so the metadata fetch must be
+        // rejected by platform-default trust and fail the build rather than silently succeeding.
+        AuthenticationOAuth2 auth = new AuthenticationOAuth2();
+        auth.configure("{\"type\":\"client_credentials\",\"issuerUrl\":\"" + idp.baseUrl()
+                + "\",\"privateKey\":\"data:application/json;base64,e30=\"}");
+
+        assertThatThrownBy(() -> PulsarAdmin.builder()
+                .serviceHttpUrl("http://localhost:8080")
+                .authentication(auth)
+                .build())
+                .as("platform-default trust must reject the test-CA-signed IdP certificate, failing the build")
+                // The OAuth2 metadata resolver reports the failure without chaining the TLS cause, so this
+                // asserts the observable outcome: with the folded trust the same flow reaches the IdP (test
+                // above), without it the handshake fails and the build does not complete.
+                .satisfies(t -> assertThat(causeChain(t)).contains("OAuth 2.0 server metadata"));
+    }
+
+    /** Issue the IdP request through exactly the client the OAuth2 plugin would use for its token fetch. */
+    private HttpResponse probe(PulsarAdminImpl admin) throws Exception {
+        FrameworkHttpClientFactory factory = admin.authHttpClientFactoryForTest();
+        assertThat(factory).as("the framework HTTP client factory must be bound").isNotNull();
+        PulsarHttpClient client = factory.newHttpClient(PulsarHttpClientConfig.builder(TlsPurpose.CLIENT_OAUTH2)
+                .connectTimeout(Duration.ofSeconds(10))
+                .readTimeout(Duration.ofSeconds(10))
+                .requestTimeout(Duration.ofSeconds(10))
+                .userAgent("admin-oauth2-idp-tls-test")
+                .build());
+        return client.execute(HttpRequest.builder(HttpRequest.Method.GET,
+                URI.create(idp.baseUrl() + "/probe")).build()).get(30, TimeUnit.SECONDS);
+    }
+
+    private static PulsarAdminImpl adminWith(AuthenticationOAuth2 auth) throws Exception {
+        // Plaintext admin URL on purpose — see the class javadoc.
+        return (PulsarAdminImpl) PulsarAdmin.builder()
+                .serviceHttpUrl("http://localhost:8080")
+                .authentication(auth)
+                .build();
+    }
+
+    /** start() is stubbed: it would fetch OAuth2 server metadata; the fold runs before it. */
+    private AuthenticationOAuth2 oauth2WithTrust(String trustCertsFilePath) {
+        AuthenticationOAuth2 auth = new AuthenticationOAuth2() {
+            private static final long serialVersionUID = 1L;
+
+            @Override
+            public void start() {
+            }
+        };
+        auth.configure("{\"type\":\"client_credentials\",\"issuerUrl\":\"" + idp.baseUrl()
+                + "\",\"privateKey\":\"data:application/json;base64,e30=\""
+                + (trustCertsFilePath == null ? "" : ",\"trustCertsFilePath\":\"" + trustCertsFilePath + "\"")
+                + "}");
+        return auth;
+    }
+
+    /** Build a JKS keystore holding broker.cert.pem (SAN:localhost) + its key, chained to the test CA. */
+    private static Path buildServerKeystore() throws Exception {
+        PrivateKey key = PemReader.loadPrivateKeyFromPemFile(BROKER_KEY);
+        X509Certificate[] leaf = PemReader.loadCertificatesFromPemFile(BROKER_CERT);
+        X509Certificate[] ca = PemReader.loadCertificatesFromPemFile(CA_CERT);
+        Certificate[] chain = new Certificate[leaf.length + ca.length];
+        System.arraycopy(leaf, 0, chain, 0, leaf.length);
+        System.arraycopy(ca, 0, chain, leaf.length, ca.length);
+        KeyStore keyStore = KeyStore.getInstance("JKS");
+        keyStore.load(null, null);
+        keyStore.setKeyEntry("broker", key, STORE_PW.toCharArray(), chain);
+        Path path = Files.createTempFile("pip478-admin-oauth2-idp-keystore", ".jks");
+        try (OutputStream out = Files.newOutputStream(path)) {
+            keyStore.store(out, STORE_PW.toCharArray());
+        }
+        return path;
+    }
+
+    private static String causeChain(Throwable t) {
+        StringBuilder sb = new StringBuilder();
+        for (Throwable cur = t; cur != null; cur = cur.getCause()) {
+            sb.append(cur.getClass().getSimpleName()).append(':').append(String.valueOf(cur.getMessage())).append('\n');
+        }
+        return sb.toString();
+    }
+
+    private static String resource(String name) {
+        return new File(Resources.getResource(name).getPath()).getAbsolutePath();
+    }
+}
diff --git a/pulsar-client-admin/src/test/java/org/apache/pulsar/client/admin/internal/AdminTlsFactoryLifecycleTest.java b/pulsar-client-admin/src/test/java/org/apache/pulsar/client/admin/internal/AdminTlsFactoryLifecycleTest.java
new file mode 100644
index 000000000000..1432845e18cf
--- /dev/null
+++ b/pulsar-client-admin/src/test/java/org/apache/pulsar/client/admin/internal/AdminTlsFactoryLifecycleTest.java
@@ -0,0 +1,188 @@
+/*
+ * 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.pulsar.client.admin.internal;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import io.netty.handler.ssl.SslContext;
+import io.netty.handler.ssl.SslContextBuilder;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Consumer;
+import org.apache.pulsar.client.admin.PulsarAdmin;
+import org.apache.pulsar.client.admin.PulsarAdminBuilder;
+import org.apache.pulsar.tls.PulsarTlsFactory;
+import org.apache.pulsar.tls.TlsFactoryInitContext;
+import org.apache.pulsar.tls.TlsHandle;
+import org.apache.pulsar.tls.TlsPurpose;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+/**
+ * PIP-478: a {@code PulsarAdmin} must construct, initialize and close its {@link PulsarTlsFactory} exactly
+ * once.
+ *
+ * <p>This is not automatic, because an admin ends up with <em>two</em> {@code AsyncHttpConnector} instances:
+ * the one {@code PulsarAdminImpl} builds directly, and the one Jersey creates lazily on the first request.
+ * Each used to resolve its own factory, which meant a by-name custom factory was constructed and
+ * {@code initialize()}d twice — against the SPI's "called once by the framework" — and a factory adopted from
+ * the broker's admin attach had {@code close()} called on it twice. The v4 (PIP-337) code duplicated the
+ * factory the same way, but never closed it, so only the duplication existed there and not the double close.
+ *
+ * <p>{@code AsyncHttpConnectorProvider} now resolves one factory per admin and owns it; the connectors take
+ * their own subscriptions but never close it. Both tests below issue a request precisely to force the
+ * Jersey-side connector into existence — without it the duplication is invisible.
+ */
+public class AdminTlsFactoryLifecycleTest {
+
+    @BeforeMethod
+    public void setUp() {
+        CountingFactory.reset();
+    }
+
+    @Test
+    public void aByNameFactoryIsConstructedInitializedAndClosedExactlyOncePerAdmin() throws Exception {
+        PulsarAdmin admin = PulsarAdmin.builder()
+                .serviceHttpUrl("https://localhost:8443")
+                .tlsFactoryClassName(CountingFactory.class.getName())
+                .build();
+        forceJerseyConnector(admin);
+
+        assertThat(CountingFactory.INSTANTIATED).as("one factory per admin, not one per connector").hasValue(1);
+        assertThat(CountingFactory.INITIALIZED).as("the SPI says initialize is called once").hasValue(1);
+
+        admin.close();
+        assertThat(CountingFactory.CLOSED).as("and it is closed exactly once").hasValue(1);
+    }
+
+    @Test
+    public void anAdoptedFactoryIsInitializedAndClosedExactlyOnce() throws Exception {
+        // How the broker's admin attach hands in a factory it built (PulsarService.applyBrokerClientTlsFactoryToAdmin).
+        CountingFactory adopted = new CountingFactory();
+        PulsarAdminBuilder builder = PulsarAdmin.builder().serviceHttpUrl("https://localhost:8443");
+        ((PulsarAdminBuilderImpl) builder).getConf().setTlsFactory(adopted);
+        PulsarAdmin admin = builder.build();
+        forceJerseyConnector(admin);
+
+        assertThat(CountingFactory.INSTANTIATED).as("the adopted instance is used as-is").hasValue(1);
+        assertThat(CountingFactory.INITIALIZED).hasValue(1);
+
+        admin.close();
+        assertThat(CountingFactory.CLOSED)
+                .as("closing the admin must not close the adopted factory twice").hasValue(1);
+    }
+
+    @Test
+    public void aFailedAdminBuildDoesNotLeakTheResolvedFactory() throws Exception {
+        // close() is unreachable when the constructor throws, so whatever the provider resolved before the
+        // failure has to be released on the way out. The factory below fails the build by refusing to serve
+        // CLIENT_DEFAULT for an https admin URL, which is where the eager context acquisition throws.
+        CountingFactory.FAIL_CLIENT_DEFAULT.set(true);
+        assertThatThrownBy(() -> PulsarAdmin.builder()
+                .serviceHttpUrl("https://localhost:8443")
+                .tlsFactoryClassName(CountingFactory.class.getName())
+                .build())
+                .as("the build must fail for this test to be about the failure path").isNotNull();
+
+        assertThat(CountingFactory.INSTANTIATED).as("the factory was resolved before the failure").hasValue(1);
+        assertThat(CountingFactory.CLOSED)
+                .as("and must be closed on the way out: a failed build leaves no handle to close it later, so "
+                        + "it would otherwise leak the factory and its non-daemon rotation thread")
+                .hasValue(1);
+    }
+
+    /** Jersey creates its connector lazily on the first request; the request itself is expected to fail. */
+    private static void forceJerseyConnector(PulsarAdmin admin) {
+        try {
+            admin.clusters().getClusters();
+        } catch (Exception expected) {
+            // No broker is listening — reaching the transport is all this needs.
+        }
+    }
+
+    /** Counts its own construction, initialization and closing. */
+    public static class CountingFactory implements PulsarTlsFactory {
+
+        static final AtomicInteger INSTANTIATED = new AtomicInteger();
+        static final AtomicInteger INITIALIZED = new AtomicInteger();
+        static final AtomicInteger CLOSED = new AtomicInteger();
+        static final java.util.concurrent.atomic.AtomicBoolean FAIL_CLIENT_DEFAULT =
+                new java.util.concurrent.atomic.AtomicBoolean();
+
+        static void reset() {
+            INSTANTIATED.set(0);
+            INITIALIZED.set(0);
+            CLOSED.set(0);
+            FAIL_CLIENT_DEFAULT.set(false);
+        }
+
+        public CountingFactory() {
+            INSTANTIATED.incrementAndGet();
+        }
+
+        @Override
+        public CompletableFuture<Void> initialize(TlsFactoryInitContext context) {
+            INITIALIZED.incrementAndGet();
+            return CompletableFuture.completedFuture(null);
+        }
+
+        @Override
+        public <T> CompletableFuture<Optional<TlsHandle<T>>> createInstance(TlsPurpose purpose,
+                Class<T> instanceClass) {
+            if (FAIL_CLIENT_DEFAULT.get() && TlsPurpose.CLIENT_DEFAULT.equals(purpose)) {
+                return CompletableFuture.failedFuture(new IllegalStateException("cannot serve CLIENT_DEFAULT"));
+            }
+            if (instanceClass != SslContext.class) {
+                return CompletableFuture.completedFuture(Optional.empty());
+            }
+            try {
+                SslContext context = SslContextBuilder.forClient().build();
+                TlsHandle<T> handle = new TlsHandle<>() {
+                    @Override
+                    @SuppressWarnings("unchecked")
+                    public T get() {
+                        return (T) context;
+                    }
+
+                    @Override
+                    public void dispose() {
+                    }
+                };
+                return CompletableFuture.completedFuture(Optional.of(handle));
+            } catch (Exception e) {
+                return CompletableFuture.failedFuture(e);
+            }
+        }
+
+        @Override
+        public <T> CompletableFuture<Optional<TlsHandle<T>>> createInstance(TlsPurpose purpose,
+                Class<T> instanceClass, Consumer<T> onLoadOrReload) {
+            return createInstance(purpose, instanceClass).thenApply(opt -> {
+                opt.ifPresent(handle -> onLoadOrReload.accept(handle.get()));
+                return opt;
+            });
+        }
+
+        @Override
+        public void close() {
+            CLOSED.incrementAndGet();
+        }
+    }
+}
diff --git a/pulsar-client-admin/src/test/java/org/apache/pulsar/client/admin/internal/http/AsyncHttpConnectorTest.java b/pulsar-client-admin/src/test/java/org/apache/pulsar/client/admin/internal/http/AsyncHttpConnectorTest.java
index 38243976f569..96e2f734c8d3 100644
--- a/pulsar-client-admin/src/test/java/org/apache/pulsar/client/admin/internal/http/AsyncHttpConnectorTest.java
+++ b/pulsar-client-admin/src/test/java/org/apache/pulsar/client/admin/internal/http/AsyncHttpConnectorTest.java
@@ -47,6 +47,7 @@ import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 import lombok.Cleanup;
 import org.apache.pulsar.client.impl.conf.ClientConfigurationData;
+import org.apache.pulsar.common.tls.impl.TlsContextAcquisition;
 import org.apache.pulsar.common.util.FutureUtil;
 import org.asynchttpclient.Request;
 import org.asynchttpclient.RequestBuilder;
@@ -244,6 +245,33 @@ public class AsyncHttpConnectorTest {
         }
     }
 
+    @Test
+    void tlsFactoryPathBoundsPooledConnectionTtl() throws Exception {
+        // PIP-478: on the https (new TLS-factory) path the AHC connection TTL must be set so a pooled
+        // HTTPS connection cannot keep pre-rotation TLS material indefinitely — new connections pick up
+        // rotation, and this TTL bounds how long an established one survives. Inject a short TTL and assert it
+        // is honored (the default is 5 minutes).
+        int injectedTtlMs = 1234;
+        String property = TlsContextAcquisition.HTTP_TLS_ROTATION_CONNECTION_TTL_PROPERTY;
+        String previous = System.getProperty(property);
+        System.setProperty(property, Integer.toString(injectedTtlMs));
+        try {
+            ClientConfigurationData conf = new ClientConfigurationData();
+            conf.setServiceUrl("https://localhost:65535");
+            @Cleanup
+            AsyncHttpConnector connector = new AsyncHttpConnector(5000, 5000, 5000, 0, conf, false, null);
+            assertTrue(connector.getTlsFactory() != null, "https uses the new PIP-478 TLS-factory path");
+            assertEquals(connector.getConnectionTtlMs(), injectedTtlMs,
+                    "pooled HTTPS connection TTL is bounded on the rotating factory path");
+        } finally {
+            if (previous == null) {
+                System.clearProperty(property);
+            } else {
+                System.setProperty(property, previous);
+            }
+        }
+    }
+
     @Test
     void testRelativeRedirect() throws ExecutionException, InterruptedException {
         doTestRedirect("path2");
diff --git a/pulsar-client-api-v5/build.gradle.kts b/pulsar-client-api-v5/build.gradle.kts
index 6b2323163684..ed0a215ea2f8 100644
--- a/pulsar-client-api-v5/build.gradle.kts
+++ b/pulsar-client-api-v5/build.gradle.kts
@@ -22,6 +22,11 @@ plugins {
 }
 
 dependencies {
+    // PIP-478: the auth SPI's AuthenticationInitContext exposes PulsarHttpClientFactory and the
+    // client builder accepts a TlsPolicy, so the HTTP + TLS factory SPIs are part of this API's
+    // surface; both SPI modules are published public libraries like this one.
+    api(project(":pulsar-tls-factory-api"))
+    api(project(":pulsar-http-client-api"))
     compileOnly(libs.protobuf.java)
     compileOnly(libs.opentelemetry.api)
 }
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/PulsarClientBuilder.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/PulsarClientBuilder.java
index 14dcf8a0cd68..dc96427883ee 100644
--- a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/PulsarClientBuilder.java
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/PulsarClientBuilder.java
@@ -23,8 +23,10 @@ import java.time.Duration;
 import org.apache.pulsar.client.api.v5.auth.Authentication;
 import org.apache.pulsar.client.api.v5.config.ConnectionPolicy;
 import org.apache.pulsar.client.api.v5.config.MemorySize;
-import org.apache.pulsar.client.api.v5.config.TlsPolicy;
 import org.apache.pulsar.client.api.v5.config.TransactionPolicy;
+import org.apache.pulsar.tls.PulsarTlsFactory;
+import org.apache.pulsar.tls.TlsPolicy;
+import org.apache.pulsar.tls.TlsPurpose;
 
 /**
  * Builder for configuring and creating a {@link PulsarClient}.
@@ -55,9 +57,17 @@ public interface PulsarClientBuilder {
     PulsarClientBuilder serviceUrl(String serviceUrl);
 
     /**
-     * Set the authentication provider.
+     * Set the authentication provider from an instance supplied programmatically.
      *
-     * @param authentication the authentication provider to use for connecting to the broker
+     * <p><b>Programmatic-path contract.</b> The instance is presumed already configured — the framework
+     * does NOT call {@link Authentication#configure(java.util.Map)} on it (that step runs only for the
+     * reflective {@link #authentication(String, String)} path). The built client <em>adopts</em> the
+     * instance: it drives {@code initializeAsync(...)} before first use and {@link Authentication#close()}
+     * when the client is closed, so callers must not close the instance themselves or share one instance
+     * across clients with differing lifetimes.
+     *
+     * @param authentication the authentication provider to use for connecting to the broker; must already
+     *                       be configured
      * @return this builder instance for chaining
      */
     PulsarClientBuilder authentication(Authentication authentication);
@@ -100,16 +110,41 @@ public interface PulsarClientBuilder {
     PulsarClientBuilder transactionPolicy(TransactionPolicy policy);
 
     /**
-     * Configure TLS for the client connection.
+     * Configure TLS for the client connection (PIP-478), using the neutral
+     * {@link org.apache.pulsar.tls.TlsPolicy} value shared by the client builder and the
+     * server components. The policy is bound to the default client purposes.
      *
      * @param policy the TLS policy to apply to broker connections
      * @return this builder instance for chaining
-     * @see TlsPolicy#of(String)
-     * @see TlsPolicy#ofMutualTls(String, String, String)
-     * @see TlsPolicy#ofInsecure()
+     * @see TlsPolicy#pem(String, String, String)
+     * @see TlsPolicy#keyStore(String, String, String, String, String)
+     * @see TlsPolicy#insecure()
      */
     PulsarClientBuilder tlsPolicy(TlsPolicy policy);
 
+    /**
+     * Configure TLS for a specific {@link TlsPurpose} (PIP-478). Most users call
+     * {@link #tlsPolicy(TlsPolicy)}, which binds the policy to {@link TlsPurpose#CLIENT_DEFAULT}; this
+     * overload additionally lets a user configure a distinct trust domain — for example
+     * {@link TlsPurpose#CLIENT_OAUTH2} for the identity provider — with its own material.
+     *
+     * @param purpose the client TLS purpose the policy applies to
+     * @param policy  the TLS policy to apply
+     * @return this builder instance for chaining
+     */
+    PulsarClientBuilder tlsPolicy(TlsPurpose purpose, TlsPolicy policy);
+
+    /**
+     * Provide a custom {@link PulsarTlsFactory} implementation (PIP-478), for advanced deployments that
+     * source TLS material from something other than files (a KMS, an HSM-backed key manager, a
+     * per-destination workload identity). The supplied factory is <em>adopted</em>: the client
+     * initializes it and closes it when the client closes.
+     *
+     * @param factory the TLS factory to adopt
+     * @return this builder instance for chaining
+     */
+    PulsarClientBuilder tlsFactory(PulsarTlsFactory factory);
+
     /**
      * Provide a custom {@link OpenTelemetry} instance for metrics and tracing.
      *
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/PulsarClientException.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/PulsarClientException.java
index 0e6005cec3a8..f7484d9c3035 100644
--- a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/PulsarClientException.java
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/PulsarClientException.java
@@ -83,6 +83,39 @@ public class PulsarClientException extends IOException {
         public AuthenticationException(String message) {
             super(message);
         }
+
+        public AuthenticationException(Throwable cause) {
+            super(cause);
+        }
+    }
+
+    /**
+     * Transient failure while acquiring a credential (token endpoint timeout, ZTS unavailable).
+     * The caller may retry; the connection layer treats it like a network error.
+     */
+    public static final class GettingAuthenticationDataException extends PulsarClientException {
+        public GettingAuthenticationDataException(String message) {
+            super(message);
+        }
+
+        public GettingAuthenticationDataException(Throwable cause) {
+            super(cause);
+        }
+    }
+
+    /**
+     * The requested authentication capability is not supported by the plugin (used by the legacy v4
+     * adapter when a wrapped v4 provider returns null/false from the corresponding {@code hasData*}
+     * method).
+     */
+    public static final class UnsupportedAuthenticationException extends PulsarClientException {
+        public UnsupportedAuthenticationException(String message) {
+            super(message);
+        }
+
+        public UnsupportedAuthenticationException(Throwable cause) {
+            super(cause);
+        }
     }
 
     public static final class AuthorizationException extends PulsarClientException {
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/package-info.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/AuthChallenge.java
similarity index 56%
copy from pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/package-info.java
copy to pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/AuthChallenge.java
index f7f22f13a371..a08185d72a0d 100644
--- a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/package-info.java
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/AuthChallenge.java
@@ -16,15 +16,19 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+package org.apache.pulsar.client.api.v5.auth;
 
 /**
- * Authentication and encryption types.
+ * A binary-protocol {@code CommandAuthChallenge} payload handed to a
+ * {@link BinaryAuthChallengeHandler} (PIP-478).
+ *
+ * <p><b>Ownership: the array is handed off, not copied.</b> The framework transfers ownership of
+ * {@code bytes} to this value on construction and the handler reads it without copying; neither
+ * side may mutate the array afterwards. Because the component is an array, the record's generated
+ * {@code equals}/{@code hashCode} are reference-based, not value-based; do not compare
+ * {@link AuthChallenge} instances for value equality.
  *
- * <p>Provides pluggable authentication via {@link org.apache.pulsar.client.api.v5.auth.Authentication}
- * and convenience factories in {@link org.apache.pulsar.client.api.v5.auth.AuthenticationFactory},
- * as well as end-to-end encryption support via
- * {@link org.apache.pulsar.client.api.v5.auth.PublicKeyProvider} (producer side) and
- * {@link org.apache.pulsar.client.api.v5.auth.PrivateKeyProvider} (consumer side).
- * For local PEM-file-backed setups, use {@link org.apache.pulsar.client.api.v5.auth.PemFileKeyProvider}.
+ * @param bytes the challenge bytes sent by the broker
  */
-package org.apache.pulsar.client.api.v5.auth;
+public record AuthChallenge(byte[] bytes) {
+}
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/Authentication.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/Authentication.java
index 8f4f0bf19d8d..043bb91d09b0 100644
--- a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/Authentication.java
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/Authentication.java
@@ -18,53 +18,95 @@
  */
 package org.apache.pulsar.client.api.v5.auth;
 
-import java.io.Closeable;
-import org.apache.pulsar.client.api.v5.PulsarClientException;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
 
 /**
- * Pluggable authentication provider for Pulsar clients.
+ * Pluggable authentication provider for the Pulsar v5 client (PIP-478).
  *
- * <p>Implementations must be thread-safe.
+ * <p>The core interface carries only lifecycle. The actual credential work lives on opt-in
+ * <em>capability</em> interfaces — segregated by transport (Pulsar binary protocol vs HTTP) and by
+ * style (single-pass vs multi-round challenge/response) — that an implementation declares only when it
+ * supports them:
+ * <ul>
+ *   <li>{@link BinaryAuthDataProvider} — single-pass credential for the binary protocol</li>
+ *   <li>{@link HttpAuthHeadersProvider} — single-pass credential for HTTP</li>
+ *   <li>{@link BinaryAuthChallengeHandler} — multi-round challenge/response for the binary protocol</li>
+ *   <li>{@link HttpAuthChallengeHandler} — SASL-style multi-round over HTTP</li>
+ * </ul>
+ * The convenience composite {@link SinglePassAuthentication} bundles the common single-pass
+ * combination.
+ *
+ * <p>All capability methods are asynchronous ({@link CompletableFuture}), so credential acquisition
+ * never blocks the Netty event loop.
  */
-public interface Authentication extends Closeable {
+public interface Authentication extends AutoCloseable {
 
     /**
-     * The authentication method name (e.g., "token", "tls").
+     * Configuration step. Called once by the framework AFTER no-arg construction when the plugin was
+     * loaded reflectively from {@code authPluginClassName} + {@code authParams}. NOT called when the
+     * plugin was constructed programmatically by the user — that path presumes the instance is already
+     * configured. Default: no-op. Implementations override to read their parameters.
      *
-     * @return the authentication method identifier string
-     */
-    String authMethodName();
-
-    /**
-     * Get the authentication data to be sent to the broker.
+     * <p>Configuration is intentionally separate from {@link #initializeAsync}: configuration is static
+     * plugin-level data (file paths, URLs, scopes); initialization gives the plugin runtime services
+     * and may do I/O.
      *
-     * @return the authentication data containing credentials for the broker
-     * @throws PulsarClientException if the authentication data could not be obtained
+     * @param authParams the parsed authentication parameters
      */
-    AuthenticationData authData() throws PulsarClientException;
+    default void configure(Map<String, String> authParams) {
+    }
 
     /**
-     * Get the authentication data for a specific broker host.
+     * Initialization step. Called once by the framework with runtime services after configuration. May
+     * do I/O; the returned future completes when the implementation is ready to serve credentials.
+     * Never throws synchronously: all failures are reported by completing the returned future
+     * exceptionally, never by throwing on the calling thread.
+     *
+     * <p><b>Must not block the calling thread.</b> The framework may call this from an I/O thread — the
+     * binary driver initializes on the connection path, which runs on a Netty event loop — and it never
+     * waits for the returned future there: the connection's authentication rounds are chained onto it
+     * instead. So start any I/O and return promptly, off-loading blocking work to
+     * {@link AuthenticationInitContext#blockingExecutor()}. Blocking inside this method before returning
+     * the future stalls every connection sharing that event loop.
      *
-     * <p>The default implementation delegates to {@link #authData()}.
+     * <p>Called at most once per driver while it succeeds. If the returned future completes exceptionally
+     * the framework does not cache the failure: the next connection attempt calls this again, so a
+     * transient initialization failure (an unreachable credential endpoint at startup) recovers on
+     * reconnect rather than poisoning the plugin for the client's lifetime.
      *
-     * @param brokerHostName the hostname of the broker to authenticate against
-     * @return the authentication data containing credentials for the specified broker
-     * @throws PulsarClientException if the authentication data could not be obtained
+     * @param ctx the runtime services context
+     * @return a future completing when the plugin is ready
      */
-    default AuthenticationData authData(String brokerHostName) throws PulsarClientException {
-        return authData();
-    }
+    CompletableFuture<Void> initializeAsync(AuthenticationInitContext ctx);
 
     /**
-     * Initialize the authentication provider. Called once when the client is created.
+     * Capability factory — the framework's ONLY discovery mechanism. The framework never uses
+     * {@code instanceof} on a plugin instance; it asks this method for each capability it may drive. An
+     * implementation may therefore serve a capability from a separate internal class, or forward a
+     * wrapped delegate's capability, rather than implementing it on this type. The default
+     * implementation returns {@code this} when the plugin implements the requested interface directly,
+     * covering the common case with no override.
+     *
+     * <p>Contract: results are STABLE once {@link #initializeAsync} has completed — the framework may
+     * look a capability up once and cache it for the client's lifetime. The framework never queries
+     * capabilities before {@code initializeAsync} completes; a result returned before initialization is
+     * unspecified. One object may serve several
+     * capabilities. Capability objects are owned by this plugin and released by {@link #close()}; the
+     * framework never closes them individually. All capability methods must tolerate concurrent
+     * invocation (multiple connections authenticate in parallel); only the rounds of a single exchange
+     * are serialized by the framework.
      *
-     * @throws PulsarClientException if initialization fails
+     * @param kind the capability interface to look up
+     * @param <T>  the capability type
+     * @return the capability if supported, otherwise {@link Optional#empty()}
      */
-    default void initialize() throws PulsarClientException {
+    default <T> Optional<T> capability(Class<T> kind) {
+        return kind.isInstance(this) ? Optional.of(kind.cast(this)) : Optional.empty();
     }
 
     @Override
-    default void close() {
+    default void close() throws Exception {
     }
 }
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/AuthenticationCallContext.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/AuthenticationCallContext.java
new file mode 100644
index 000000000000..5ded4921c01c
--- /dev/null
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/AuthenticationCallContext.java
@@ -0,0 +1,69 @@
+/*
+ * 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.pulsar.client.api.v5.auth;
+
+import java.util.Optional;
+
+/**
+ * The per-call context for binary-protocol authentication (PIP-478).
+ *
+ * <p>Carries the broker host and exposes a per-exchange state slot for retaining
+ * conversation state across challenge-response rounds. Most implementations produce the same credential
+ * for every call and never inspect the broker host.
+ */
+public interface AuthenticationCallContext {
+
+    /**
+     * @return the broker host this connection is being established to
+     */
+    String brokerHost();
+
+    /**
+     * Retrieve an implementation-controlled state object previously stored with
+     * {@link #setStateObject}. The slot is keyed by class so the pieces of one plugin — which, under
+     * the capability-factory model, may be separate internal classes participating in the same exchange
+     * (e.g. the initial-data provider and the challenge handler on one binary connect) — can each keep
+     * state without collision; impls typically store one object of their own type (e.g. their SASL
+     * conversation state).
+     *
+     * <p>The slot's lifetime equals one authentication <em>exchange</em>: for the binary protocol, one
+     * connection attempt and all of its ordinary {@code CommandAuthChallenge} / {@code CommandAuthResponse}
+     * challenge rounds. A broker {@code REFRESH_AUTH_DATA} sentinel does not continue this exchange —
+     * {@code ClientCnx} begins a <em>new</em> exchange with a fresh context and a fresh slot — so
+     * conversation state does not survive a refresh. Concurrent authentications to different brokers get
+     * their own context with their own slots, so multiple in-flight handshakes don't collide.
+     *
+     * @param clazz the state object's key class
+     * @param <T>   the state object type
+     * @return the stored object, if present
+     */
+    <T> Optional<T> getStateObject(Class<T> clazz);
+
+    /**
+     * Store a state object keyed by its (or any) class; a {@code null} value removes the entry.
+     * Implementations should key with a private class of their own (e.g. their conversation record) to
+     * avoid collisions. Rounds of one exchange are serialized by the framework, so slot access within an
+     * exchange needs no synchronization.
+     *
+     * @param clazz the key class
+     * @param value the state object, or {@code null} to remove the entry
+     * @param <T>   the state object type
+     */
+    <T> void setStateObject(Class<T> clazz, T value);
+}
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/AuthenticationData.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/AuthenticationData.java
deleted file mode 100644
index c8ab63410cf7..000000000000
--- a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/AuthenticationData.java
+++ /dev/null
@@ -1,99 +0,0 @@
-/*
- * 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.pulsar.client.api.v5.auth;
-
-import java.security.PrivateKey;
-import java.security.cert.Certificate;
-import java.util.Map;
-import java.util.Set;
-
-/**
- * Provides authentication credentials for different transport mechanisms.
- */
-public interface AuthenticationData {
-
-    // --- HTTP authentication ---
-
-    /**
-     * Check whether this authentication data provides HTTP headers.
-     *
-     * @return {@code true} if HTTP authentication headers are available, {@code false} otherwise
-     */
-    default boolean hasDataForHttp() {
-        return false;
-    }
-
-    /**
-     * Get the HTTP authentication headers to include in requests.
-     *
-     * @return a set of header name-value entries for HTTP authentication, or an empty set if none
-     */
-    default Set<Map.Entry<String, String>> getHttpHeaders() {
-        return Set.of();
-    }
-
-    // --- TLS mutual authentication ---
-
-    /**
-     * Check whether this authentication data provides TLS client certificates.
-     *
-     * @return {@code true} if TLS certificate data is available, {@code false} otherwise
-     */
-    default boolean hasDataForTls() {
-        return false;
-    }
-
-    /**
-     * Get the TLS client certificate chain for mutual authentication.
-     *
-     * @return the client certificate chain, or {@code null} if not available
-     */
-    default Certificate[] getTlsCertificates() {
-        return null;
-    }
-
-    /**
-     * Get the TLS client private key for mutual authentication.
-     *
-     * @return the client private key, or {@code null} if not available
-     */
-    default PrivateKey getTlsPrivateKey() {
-        return null;
-    }
-
-    // --- Binary protocol authentication ---
-
-    /**
-     * Check whether this authentication data provides binary protocol command data.
-     *
-     * @return {@code true} if command data is available for the Pulsar binary protocol, {@code false} otherwise
-     */
-    default boolean hasDataFromCommand() {
-        return false;
-    }
-
-    /**
-     * Get the authentication data to include in binary protocol commands.
-     *
-     * @return the command data string for binary protocol authentication, or {@code null} if not available
-     */
-    default String getCommandData() {
-        return null;
-    }
-}
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/AuthenticationFactory.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/AuthenticationFactory.java
index 705b2aa429ee..23433a62b824 100644
--- a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/AuthenticationFactory.java
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/AuthenticationFactory.java
@@ -55,14 +55,18 @@ public final class AuthenticationFactory {
     }
 
     /**
-     * Create TLS mutual authentication.
+     * Create the built-in TLS mutual-authentication marker plugin (PIP-478).
      *
-     * @param certFilePath the path to the client certificate file (PEM format)
-     * @param keyFilePath  the path to the client private key file (PEM format)
-     * @return an {@link Authentication} instance configured for TLS mutual authentication
+     * <p>This plugin carries no TLS material of its own: it only advertises the {@code "tls"}
+     * authentication method so the broker authenticates from the client certificate presented during the
+     * TLS handshake. Configure the client certificate, private key, and trust material through the client
+     * builder's {@code tlsPolicy(...)} (e.g. {@code TlsPolicy.pem(trustCerts, certFile, keyFile)}); the
+     * transport reads its material from the client TLS factory.
+     *
+     * @return an {@link Authentication} instance that advertises the {@code "tls"} method
      */
-    public static Authentication tls(String certFilePath, String keyFilePath) {
-        return PulsarClientProvider.get().authenticationTls(certFilePath, keyFilePath);
+    public static Authentication tls() {
+        return PulsarClientProvider.get().authenticationTls();
     }
 
     /**
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/AuthenticationInitContext.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/AuthenticationInitContext.java
new file mode 100644
index 000000000000..bccd0845fe7c
--- /dev/null
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/AuthenticationInitContext.java
@@ -0,0 +1,85 @@
+/*
+ * 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.pulsar.client.api.v5.auth;
+
+import io.opentelemetry.api.OpenTelemetry;
+import java.time.Clock;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ScheduledExecutorService;
+import org.apache.pulsar.http.PulsarHttpClientFactory;
+
+/**
+ * Runtime services handed to {@link Authentication#initializeAsync} once when the client is built
+ * (PIP-478).
+ *
+ * <p>The framework owns and closes these shared services (HTTP clients, scheduler, blocking executor);
+ * the implementation may retain references for the lifetime of the client and releases its own
+ * resources in {@link Authentication#close()}.
+ */
+public interface AuthenticationInitContext {
+
+    /**
+     * Factory for {@link org.apache.pulsar.http.PulsarHttpClient} instances. The
+     * framework manages lifecycle (event loop, timer, DNS cache, TLS material refresh integration);
+     * plugins describe what they need via a
+     * {@link org.apache.pulsar.http.PulsarHttpClientConfig} and receive a configured
+     * instance owned by the framework. Multiple instances with different TLS / timeouts may be
+     * obtained for different uses (e.g. OAuth2 mTLS to the IdP vs HTTP topic lookup).
+     *
+     * <p>Plugins MUST NOT construct private HTTP clients directly — doing so defeats the framework's
+     * shared event-loop / DNS / refresh integration.
+     *
+     * @return the framework's HTTP client factory
+     */
+    PulsarHttpClientFactory httpClientFactory();
+
+    /**
+     * Scheduler for delayed / periodic authentication work (e.g. proactive credential renewal). Never
+     * the Netty event loop. Reserved for scheduled tasks — potentially-blocking work belongs on
+     * {@link #blockingExecutor()}.
+     *
+     * @return the framework-owned scheduler
+     */
+    ScheduledExecutorService scheduler();
+
+    /**
+     * Dedicated executor for off-loading potentially-blocking authentication work, kept separate from
+     * {@link #scheduler()} so that blocking calls cannot starve the scheduler's threads or delay
+     * scheduled tasks. The {@code LegacyV4AuthenticationAdapter} offloads every synchronous v4 plugin
+     * call here. Never the Netty event loop. Framework-owned and shared per {@code PulsarClient}.
+     *
+     * @return the framework-owned blocking executor
+     */
+    Executor blockingExecutor();
+
+    /**
+     * @return the clock used by implementations that schedule against wall-clock time
+     */
+    Clock clock();
+
+    /**
+     * @return the telemetry root; the framework defaults to {@link OpenTelemetry#noop()} if unset
+     */
+    OpenTelemetry openTelemetry();
+
+    /**
+     * @return a stable id of the owning {@code PulsarClient}, for logging correlation
+     */
+    String clientInstanceId();
+}
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/BinaryAuthChallengeHandler.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/BinaryAuthChallengeHandler.java
new file mode 100644
index 000000000000..32c38c8be7a5
--- /dev/null
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/BinaryAuthChallengeHandler.java
@@ -0,0 +1,49 @@
+/*
+ * 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.pulsar.client.api.v5.auth;
+
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Capability: multi-round challenge/response (SASL-style and custom protocols) for the Pulsar binary
+ * protocol (PIP-478).
+ *
+ * <p>The broker's {@code REFRESH_AUTH_DATA} refresh sentinel is <em>never</em> delivered here (binary
+ * routing rule 2): a refresh re-produces the current credential through
+ * {@link BinaryAuthDataProvider} on a fresh exchange, so this handler only ever sees a genuine challenge
+ * payload — a real round of the challenge conversation.
+ */
+public interface BinaryAuthChallengeHandler {
+
+    /**
+     * Respond to a binary-protocol {@code CommandAuthChallenge}.
+     *
+     * <p>The framework places the returned bytes in {@code CommandAuthResponse}. Completion is decided
+     * by the broker (it replies with {@code CommandConnected} when satisfied), so the handler does not
+     * signal it; cross-round conversation state is kept in the context's state slot. Never throws
+     * synchronously: all failures are reported by completing the returned future exceptionally, never by
+     * throwing on the calling thread.
+     *
+     * @param ctx           the per-call context (holds cross-round state)
+     * @param authChallenge the challenge from the broker
+     * @return a future of the response for the next {@code CommandAuthResponse}
+     */
+    CompletableFuture<ChallengeResponse> respondToChallengeAsync(AuthenticationCallContext ctx,
+                                                                 AuthChallenge authChallenge);
+}
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/BinaryAuthData.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/BinaryAuthData.java
new file mode 100644
index 000000000000..a3a001da8a5f
--- /dev/null
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/BinaryAuthData.java
@@ -0,0 +1,40 @@
+/*
+ * 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.pulsar.client.api.v5.auth;
+
+/**
+ * The binary-protocol credential: the {@code auth_data} bytes for {@code CommandConnect} (PIP-478).
+ *
+ * <p>The auth-method name is deliberately NOT part of this value — it comes from exactly one place,
+ * {@link BinaryAuthDataProvider#authMethodName()}, so a plugin cannot contradict itself (an earlier
+ * draft carried the name on both the provider and the data value, leaving "which one wins?" to the
+ * javadoc). Kept as a record rather than a bare {@code byte[]} so fields can be added later without an
+ * API break.
+ *
+ * <p><b>Ownership: the array is handed off, not copied.</b> The producer transfers ownership of
+ * {@code bytes} to this value on construction, and the framework reads it without copying; neither
+ * side may mutate the array after construction. (Copying is deliberately avoided — this is a
+ * single-producer-to-single-consumer transfer, matching the v4 {@code AuthData} contract.) Because the
+ * component is an array, the record's generated {@code equals}/{@code hashCode} are reference-based on
+ * the bytes, not value-based; do not compare {@link BinaryAuthData} instances for value equality.
+ *
+ * @param bytes the credential bytes sent in {@code CommandConnect.auth_data}
+ */
+public record BinaryAuthData(byte[] bytes) {
+}
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/BinaryAuthDataProvider.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/BinaryAuthDataProvider.java
new file mode 100644
index 000000000000..7e06b6febd3b
--- /dev/null
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/BinaryAuthDataProvider.java
@@ -0,0 +1,49 @@
+/*
+ * 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.pulsar.client.api.v5.auth;
+
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Capability: single-pass credential exchange for the Pulsar binary protocol (PIP-478).
+ *
+ * <p>A plugin implements this when it can present a credential the broker accepts or rejects in a
+ * single round trip (token, basic, OAuth2 access token, Athenz role token, or mTLS via the built-in
+ * {@code TlsAuthentication}). This capability is required for any plugin used on the binary transport.
+ */
+public interface BinaryAuthDataProvider {
+
+    /**
+     * @return the stable identifier sent in {@code CommandConnect.auth_method_name}
+     */
+    String authMethodName();
+
+    /**
+     * Produce a credential for the binary-protocol connection. The returned {@link BinaryAuthData}
+     * carries the {@code auth_data} bytes for {@code CommandConnect}; the framework pairs them with
+     * {@link #authMethodName()}. Completes exceptionally on failure (see the error model in
+     * {@link org.apache.pulsar.client.api.v5.PulsarClientException}). Never throws synchronously: all
+     * failures are reported by completing the returned future exceptionally, never by throwing on the
+     * calling thread.
+     *
+     * @param ctx the per-call context
+     * @return a future of the binary-protocol credential
+     */
+    CompletableFuture<BinaryAuthData> getAuthDataAsync(AuthenticationCallContext ctx);
+}
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/ChallengeResponse.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/ChallengeResponse.java
new file mode 100644
index 000000000000..b9bc8d58ca1d
--- /dev/null
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/ChallengeResponse.java
@@ -0,0 +1,39 @@
+/*
+ * 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.pulsar.client.api.v5.auth;
+
+/**
+ * The reply from {@link BinaryAuthChallengeHandler#respondToChallengeAsync} — the bytes for the next
+ * {@code CommandAuthResponse} (PIP-478).
+ *
+ * <p>There is no completion flag: the broker decides when the handshake is finished (it sends
+ * {@code CommandConnected}); the implementation tracks any cross-round state of its own in the call
+ * context's state slot. Kept as a record rather than a bare {@code byte[]} so fields can be added later
+ * without an API break.
+ *
+ * <p><b>Ownership: the array is handed off, not copied.</b> The handler transfers ownership of
+ * {@code bytes} to this value on construction and the framework reads it without copying;
+ * neither side may mutate the array afterwards. Because the component is an array, the record's
+ * generated {@code equals}/{@code hashCode} are reference-based, not value-based; do not compare
+ * {@link ChallengeResponse} instances for value equality.
+ *
+ * @param bytes the bytes to send in the next {@code CommandAuthResponse}
+ */
+public record ChallengeResponse(byte[] bytes) {
+}
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/HttpAuthCallContext.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/HttpAuthCallContext.java
new file mode 100644
index 000000000000..bb65ce694e4e
--- /dev/null
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/HttpAuthCallContext.java
@@ -0,0 +1,68 @@
+/*
+ * 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.pulsar.client.api.v5.auth;
+
+import java.net.URI;
+import java.util.Optional;
+
+/**
+ * The per-call context for HTTP authentication (PIP-478).
+ *
+ * <p>Carries the request URI, a per-exchange state slot (with the same contract as on
+ * {@link AuthenticationCallContext}), and — for SASL-style challenge/response — the server's challenge
+ * headers from the prior {@code 401} response.
+ */
+public interface HttpAuthCallContext {
+
+    /**
+     * @return the URI of the request being authenticated
+     */
+    URI requestUri();
+
+    /**
+     * For SASL-style HTTP challenge/response: the challenge headers from the server's prior
+     * {@code 401} response (for example {@code SASL-Token} / {@code State} / {@code SASL-Server-ID}).
+     * Empty on the first request, before any challenge.
+     *
+     * @return the server's challenge headers, if a challenge has been received
+     */
+    Optional<HttpAuthHeaders> serverChallengeHeaders();
+
+    /**
+     * Retrieve an implementation-controlled state object previously stored with
+     * {@link #setStateObject}. The slot is keyed by class; its lifetime equals one HTTP request's retry
+     * sequence. See {@link AuthenticationCallContext#getStateObject} for the full contract.
+     *
+     * @param clazz the state object's key class
+     * @param <T>   the state object type
+     * @return the stored object, if present
+     */
+    <T> Optional<T> getStateObject(Class<T> clazz);
+
+    /**
+     * Store a state object keyed by its (or any) class; a {@code null} value removes the entry. Rounds
+     * of one exchange are serialized by the framework, so slot access within an exchange needs no
+     * synchronization.
+     *
+     * @param clazz the key class
+     * @param value the state object, or {@code null} to remove the entry
+     * @param <T>   the state object type
+     */
+    <T> void setStateObject(Class<T> clazz, T value);
+}
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/HttpAuthChallengeHandler.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/HttpAuthChallengeHandler.java
new file mode 100644
index 000000000000..1ba69c4df0d8
--- /dev/null
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/HttpAuthChallengeHandler.java
@@ -0,0 +1,49 @@
+/*
+ * 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.pulsar.client.api.v5.auth;
+
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Capability: SASL-style multi-round challenge/response over HTTP (PIP-478).
+ *
+ * <p>Handles the existing Pulsar SASL-over-HTTP mechanism, where the server returns HTTP {@code 401}
+ * carrying the challenge in Pulsar's custom SASL headers (for example {@code SASL-Token} /
+ * {@code State} / {@code SASL-Server-ID}) rather than the standard {@code WWW-Authenticate} header.
+ *
+ * <p>Standard {@code WWW-Authenticate}-based schemes (for example HTTP digest) are expected to be
+ * served by a separate sibling interface; the framework's HTTP auth driver dispatches to whichever a
+ * plugin implements.
+ */
+public interface HttpAuthChallengeHandler {
+
+    /**
+     * Compute the headers for the next round.
+     *
+     * <p>The server's challenge headers from the prior {@code 401} are available via
+     * {@link HttpAuthCallContext#serverChallengeHeaders()}; the returned {@link HttpAuthHeaders} are
+     * attached to the resubmitted request. Cross-round conversation state is kept in the context's
+     * state slot. Never throws synchronously: all failures are reported by completing the returned
+     * future exceptionally, never by throwing on the calling thread.
+     *
+     * @param ctx the per-call context (holds the server challenge and cross-round state)
+     * @return a future of the headers for the resubmitted request
+     */
+    CompletableFuture<HttpAuthHeaders> respondToHttpChallengeAsync(HttpAuthCallContext ctx);
+}
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/HttpAuthHeaders.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/HttpAuthHeaders.java
new file mode 100644
index 000000000000..82b3d5031961
--- /dev/null
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/HttpAuthHeaders.java
@@ -0,0 +1,118 @@
+/*
+ * 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.pulsar.client.api.v5.auth;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * HTTP authentication headers an implementation produces for an outgoing request (and, for
+ * challenge/response, the headers carrying a server's challenge) (PIP-478).
+ *
+ * <p>Header names are canonicalised on construction (RFC 7230 §3.2 treats them case-insensitively);
+ * {@link #get(String)} is case-insensitive; {@link #asMap()} returns a stable canonical-cased view.
+ *
+ * <p><b>Single value per name.</b> This collapses to one value per canonical name: when the source map
+ * passed to {@link #of(Map)} holds two entries that canonicalise to the same name (case-variants such as
+ * {@code Authorization} and {@code authorization}), the one visited last in the source map's iteration
+ * order wins. Multi-valued headers are not represented.
+ */
+public final class HttpAuthHeaders {
+
+    private static final HttpAuthHeaders EMPTY = new HttpAuthHeaders(Collections.emptyMap());
+
+    private final Map<String, String> headers;
+
+    private HttpAuthHeaders(Map<String, String> headers) {
+        this.headers = headers;
+    }
+
+    /**
+     * @return a shared empty instance
+     */
+    public static HttpAuthHeaders empty() {
+        return EMPTY;
+    }
+
+    /**
+     * Create headers with a single entry.
+     *
+     * @param name  the header name
+     * @param value the header value
+     * @return a new {@link HttpAuthHeaders}
+     */
+    public static HttpAuthHeaders of(String name, String value) {
+        Map<String, String> map = new LinkedHashMap<>(1);
+        map.put(canonical(name), value);
+        return new HttpAuthHeaders(Collections.unmodifiableMap(map));
+    }
+
+    /**
+     * Create headers from a map. Names are canonicalised; two source entries that canonicalise to the
+     * same name (case-variants) collapse to one, and the entry visited last in {@code headers}' iteration
+     * order wins.
+     *
+     * @param headers the source headers (names canonicalised)
+     * @return a new {@link HttpAuthHeaders}
+     */
+    public static HttpAuthHeaders of(Map<String, String> headers) {
+        if (headers == null || headers.isEmpty()) {
+            return EMPTY;
+        }
+        Map<String, String> map = new LinkedHashMap<>(headers.size());
+        headers.forEach((k, v) -> map.put(canonical(k), v));
+        return new HttpAuthHeaders(Collections.unmodifiableMap(map));
+    }
+
+    /**
+     * Look up a header value, case-insensitively.
+     *
+     * @param name the header name
+     * @return the header value if present
+     */
+    public Optional<String> get(String name) {
+        return Optional.ofNullable(headers.get(canonical(name)));
+    }
+
+    /**
+     * @return an unmodifiable, canonical-cased view of the headers
+     */
+    public Map<String, String> asMap() {
+        return headers;
+    }
+
+    /**
+     * @return {@code true} if there are no headers
+     */
+    public boolean isEmpty() {
+        return headers.isEmpty();
+    }
+
+    private static String canonical(String name) {
+        return name == null ? null : name.toLowerCase(Locale.ROOT);
+    }
+
+    @Override
+    public String toString() {
+        return "HttpAuthHeaders" + headers.keySet();
+    }
+}
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/HttpAuthHeadersProvider.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/HttpAuthHeadersProvider.java
new file mode 100644
index 000000000000..b13a8eb974b6
--- /dev/null
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/HttpAuthHeadersProvider.java
@@ -0,0 +1,42 @@
+/*
+ * 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.pulsar.client.api.v5.auth;
+
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Capability: single-pass credential exchange for the Pulsar HTTP transport (PIP-478).
+ */
+public interface HttpAuthHeadersProvider {
+
+    /**
+     * Produce the authentication headers for an outgoing HTTP request (for example
+     * {@code Authorization: Bearer <jwt>}, or a custom header such as {@code Athenz-Role-Auth}).
+     *
+     * <p>The returned {@link HttpAuthHeaders} are attached to the request; most implementations produce
+     * the same credential for every call. Completes exceptionally on failure (see the error model in
+     * {@link org.apache.pulsar.client.api.v5.PulsarClientException}). Never throws synchronously: all
+     * failures are reported by completing the returned future exceptionally, never by throwing on the
+     * calling thread.
+     *
+     * @param ctx the per-call context
+     * @return a future of the HTTP authentication headers
+     */
+    CompletableFuture<HttpAuthHeaders> getHttpHeadersAsync(HttpAuthCallContext ctx);
+}
diff --git a/pulsar-client-api-v5/build.gradle.kts b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/SinglePassAuthentication.java
similarity index 65%
copy from pulsar-client-api-v5/build.gradle.kts
copy to pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/SinglePassAuthentication.java
index 6b2323163684..caad9ea92f5f 100644
--- a/pulsar-client-api-v5/build.gradle.kts
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/SinglePassAuthentication.java
@@ -16,12 +16,14 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+package org.apache.pulsar.client.api.v5.auth;
 
-plugins {
-    id("pulsar.public-java-library-conventions")
-}
-
-dependencies {
-    compileOnly(libs.protobuf.java)
-    compileOnly(libs.opentelemetry.api)
+/**
+ * Convenience composite: a single-pass credential served over both transports (PIP-478).
+ *
+ * <p>Adds no methods — an implementation may declare this instead of listing
+ * {@link BinaryAuthDataProvider} and {@link HttpAuthHeadersProvider} separately.
+ */
+public interface SinglePassAuthentication
+        extends Authentication, BinaryAuthDataProvider, HttpAuthHeadersProvider {
 }
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/package-info.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/package-info.java
index f7f22f13a371..68c57aa8f163 100644
--- a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/package-info.java
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/auth/package-info.java
@@ -18,13 +18,58 @@
  */
 
 /**
- * Authentication and encryption types.
+ * Pluggable authentication (and end-to-end encryption) types for the v5 client (PIP-478).
  *
- * <p>Provides pluggable authentication via {@link org.apache.pulsar.client.api.v5.auth.Authentication}
- * and convenience factories in {@link org.apache.pulsar.client.api.v5.auth.AuthenticationFactory},
- * as well as end-to-end encryption support via
+ * <p>{@link org.apache.pulsar.client.api.v5.auth.Authentication} carries only lifecycle
+ * ({@code configure} / {@code initializeAsync} / {@code capability} / {@code close}); the credential work
+ * lives on opt-in <em>capability</em> interfaces the framework discovers <em>only</em> through
+ * {@link org.apache.pulsar.client.api.v5.auth.Authentication#capability(Class)} — never
+ * {@code instanceof}. The capabilities are segregated on two axes (transport × style):
+ *
+ * <table border="1">
+ *   <caption>The capability matrix</caption>
+ *   <tr><th></th><th>Pulsar binary protocol</th><th>HTTP</th></tr>
+ *   <tr><td><b>Single-pass</b></td>
+ *       <td>{@link org.apache.pulsar.client.api.v5.auth.BinaryAuthDataProvider}</td>
+ *       <td>{@link org.apache.pulsar.client.api.v5.auth.HttpAuthHeadersProvider}</td></tr>
+ *   <tr><td><b>Multi-round challenge/response</b></td>
+ *       <td>{@link org.apache.pulsar.client.api.v5.auth.BinaryAuthChallengeHandler}</td>
+ *       <td>{@link org.apache.pulsar.client.api.v5.auth.HttpAuthChallengeHandler}</td></tr>
+ * </table>
+ *
+ * <p>The convenience composite {@link org.apache.pulsar.client.api.v5.auth.SinglePassAuthentication}
+ * bundles the common single-pass-over-both-transports combination. All capability methods are
+ * asynchronous, so credential acquisition never blocks the Netty event loop.
+ *
+ * <p><b>Two configuration paths.</b>
+ * <ul>
+ *   <li><em>Programmatic</em> — build and configure an instance yourself, then pass it to the builder's
+ *       {@code authentication(Authentication)}. {@link org.apache.pulsar.client.api.v5.auth.Authentication#configure}
+ *       is NOT called (the instance is presumed already configured). The convenience factories in
+ *       {@link org.apache.pulsar.client.api.v5.auth.AuthenticationFactory} return such instances.</li>
+ *   <li><em>Reflective by class name</em> — {@code authentication(authPluginClassName, authParams)}: the
+ *       framework loads the class, constructs it no-arg, and calls
+ *       {@link org.apache.pulsar.client.api.v5.auth.Authentication#configure} with the parsed params. A
+ *       class implementing only this v5 SPI works over this path exactly like a built-in.</li>
+ * </ul>
+ *
+ * <p><b>Binary routing rules (normative).</b> For every plugin, {@code ClientCnx} routes as:
+ * <ol>
+ *   <li><b>Initial connect</b> &rarr; {@link org.apache.pulsar.client.api.v5.auth.BinaryAuthDataProvider};
+ *       this capability is required for the binary transport — a plugin that does not expose it fails at
+ *       client build with an {@code UnsupportedAuthenticationException}.</li>
+ *   <li><b>The refresh sentinel</b> ({@code AuthData.REFRESH_AUTH_DATA_BYTES}) &rarr; the current
+ *       credential is re-produced through {@code BinaryAuthDataProvider} on a <em>fresh</em> exchange; the
+ *       sentinel is filtered out and never reaches the challenge handler.</li>
+ *   <li><b>Any other challenge</b> &rarr;
+ *       {@link org.apache.pulsar.client.api.v5.auth.BinaryAuthChallengeHandler}; a plugin that receives a
+ *       challenge without exposing the handler fails the connection with an
+ *       {@code AuthenticationException}.</li>
+ * </ol>
+ *
+ * <p>The package also provides end-to-end encryption support via
  * {@link org.apache.pulsar.client.api.v5.auth.PublicKeyProvider} (producer side) and
- * {@link org.apache.pulsar.client.api.v5.auth.PrivateKeyProvider} (consumer side).
- * For local PEM-file-backed setups, use {@link org.apache.pulsar.client.api.v5.auth.PemFileKeyProvider}.
+ * {@link org.apache.pulsar.client.api.v5.auth.PrivateKeyProvider} (consumer side); for local
+ * PEM-file-backed setups use {@link org.apache.pulsar.client.api.v5.auth.PemFileKeyProvider}.
  */
 package org.apache.pulsar.client.api.v5.auth;
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/config/TlsPolicy.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/config/TlsPolicy.java
deleted file mode 100644
index ebd2e8ca1963..000000000000
--- a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/config/TlsPolicy.java
+++ /dev/null
@@ -1,177 +0,0 @@
-/*
- * 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.pulsar.client.api.v5.config;
-
-import lombok.EqualsAndHashCode;
-import lombok.ToString;
-
-/**
- * TLS configuration for the Pulsar client connection.
- *
- * <p>Construct via {@link #builder()}, or use {@link #ofInsecure()} for development.
- */
-@EqualsAndHashCode
-@ToString
-public final class TlsPolicy {
-
-    private final String trustCertsFilePath;
-    private final String keyFilePath;
-    private final String certificateFilePath;
-    private final boolean allowInsecureConnection;
-    private final boolean enableHostnameVerification;
-
-    private TlsPolicy(String trustCertsFilePath, String keyFilePath, String certificateFilePath,
-                      boolean allowInsecureConnection, boolean enableHostnameVerification) {
-        this.trustCertsFilePath = trustCertsFilePath;
-        this.keyFilePath = keyFilePath;
-        this.certificateFilePath = certificateFilePath;
-        this.allowInsecureConnection = allowInsecureConnection;
-        this.enableHostnameVerification = enableHostnameVerification;
-    }
-
-    /**
-     * @return path to the trusted CA certificate file (PEM), or {@code null} when not set
-     */
-    public String trustCertsFilePath() {
-        return trustCertsFilePath;
-    }
-
-    /**
-     * @return path to the client private key file (PEM), or {@code null} when not using mTLS
-     */
-    public String keyFilePath() {
-        return keyFilePath;
-    }
-
-    /**
-     * @return path to the client certificate file (PEM), or {@code null} when not using mTLS
-     */
-    public String certificateFilePath() {
-        return certificateFilePath;
-    }
-
-    /**
-     * @return whether connections to brokers with untrusted certificates are allowed
-     */
-    public boolean allowInsecureConnection() {
-        return allowInsecureConnection;
-    }
-
-    /**
-     * @return whether the broker hostname is verified against the certificate
-     */
-    public boolean enableHostnameVerification() {
-        return enableHostnameVerification;
-    }
-
-    /**
-     * Create an insecure TLS policy that accepts any certificate (for development only).
-     *
-     * @return a {@link TlsPolicy} with insecure connections allowed and hostname verification disabled
-     */
-    public static TlsPolicy ofInsecure() {
-        return new TlsPolicy(null, null, null, true, false);
-    }
-
-    /**
-     * @return a new builder for constructing a {@link TlsPolicy}
-     */
-    public static Builder builder() {
-        return new Builder();
-    }
-
-    /**
-     * Builder for {@link TlsPolicy}.
-     */
-    public static final class Builder {
-        private String trustCertsFilePath;
-        private String keyFilePath;
-        private String certificateFilePath;
-        private boolean allowInsecureConnection = false;
-        private boolean enableHostnameVerification = true;
-
-        private Builder() {
-        }
-
-        /**
-         * Path to the trusted CA certificate file (PEM format).
-         *
-         * @param trustCertsFilePath the trust store path
-         * @return this builder
-         */
-        public Builder trustCertsFilePath(String trustCertsFilePath) {
-            this.trustCertsFilePath = trustCertsFilePath;
-            return this;
-        }
-
-        /**
-         * Path to the client private key file (PEM format) for mutual TLS.
-         *
-         * @param keyFilePath the client key path
-         * @return this builder
-         */
-        public Builder keyFilePath(String keyFilePath) {
-            this.keyFilePath = keyFilePath;
-            return this;
-        }
-
-        /**
-         * Path to the client certificate file (PEM format) for mutual TLS.
-         *
-         * @param certificateFilePath the client certificate path
-         * @return this builder
-         */
-        public Builder certificateFilePath(String certificateFilePath) {
-            this.certificateFilePath = certificateFilePath;
-            return this;
-        }
-
-        /**
-         * Whether to allow connecting to brokers with untrusted certificates.
-         * Default {@code false}. Enable only for development.
-         *
-         * @param allowInsecureConnection whether to skip certificate validation
-         * @return this builder
-         */
-        public Builder allowInsecureConnection(boolean allowInsecureConnection) {
-            this.allowInsecureConnection = allowInsecureConnection;
-            return this;
-        }
-
-        /**
-         * Whether to verify the broker hostname against the certificate. Default
-         * {@code true}.
-         *
-         * @param enableHostnameVerification whether to verify the hostname
-         * @return this builder
-         */
-        public Builder enableHostnameVerification(boolean enableHostnameVerification) {
-            this.enableHostnameVerification = enableHostnameVerification;
-            return this;
-        }
-
-        /**
-         * @return a new {@link TlsPolicy} instance
-         */
-        public TlsPolicy build() {
-            return new TlsPolicy(trustCertsFilePath, keyFilePath, certificateFilePath,
-                    allowInsecureConnection, enableHostnameVerification);
-        }
-    }
-}
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/config/package-info.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/config/package-info.java
index afc47322a1c0..c2eacd980e15 100644
--- a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/config/package-info.java
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/config/package-info.java
@@ -22,8 +22,8 @@
  *
  * <p>Immutable records used to group related configuration parameters in builder APIs,
  * such as {@link org.apache.pulsar.client.api.v5.config.BatchingPolicy},
- * {@link org.apache.pulsar.client.api.v5.config.BackoffPolicy},
- * {@link org.apache.pulsar.client.api.v5.config.TlsPolicy}, and
- * {@link org.apache.pulsar.client.api.v5.config.DeadLetterPolicy}.
+ * {@link org.apache.pulsar.client.api.v5.config.BackoffPolicy}, and
+ * {@link org.apache.pulsar.client.api.v5.config.DeadLetterPolicy}. TLS configuration lives in the
+ * neutral {@link org.apache.pulsar.tls.TlsPolicy} value (PIP-478).
  */
 package org.apache.pulsar.client.api.v5.config;
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/internal/ClientAuthenticationServices.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/internal/ClientAuthenticationServices.java
new file mode 100644
index 000000000000..3e8a00bc8139
--- /dev/null
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/internal/ClientAuthenticationServices.java
@@ -0,0 +1,78 @@
+/*
+ * 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.pulsar.client.api.v5.internal;
+
+import io.opentelemetry.api.OpenTelemetry;
+import java.time.Clock;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ScheduledExecutorService;
+import org.apache.pulsar.http.PulsarHttpClientFactory;
+
+/**
+ * The framework-owned runtime services a {@code PulsarClient} late-binds into its authentication driver
+ * once the client instance exists (PIP-478).
+ *
+ * <p>An {@code Authentication} plugin is configured on the client builder <em>before</em> the client is
+ * constructed (via {@code conf.setAuthentication(...)}), so these services cannot be supplied at plugin
+ * construction time. The client therefore binds them right after it has created its own executors and
+ * HTTP client factory, but <em>before</em> it starts the plugin — see
+ * {@link ClientAuthenticationServicesAware}. The services mirror the fields the plugin ultimately sees
+ * through its {@link org.apache.pulsar.client.api.v5.auth.AuthenticationInitContext}.
+ *
+ * <p>The framework owns and closes these shared services; a driver may retain the reference for the
+ * lifetime of the client.
+ *
+ * <p>The {@code .internal.} subpackage signals "stable internal" — application code should not consume
+ * this type; it is passed between the framework's own client and its authentication bridges.
+ */
+public interface ClientAuthenticationServices {
+
+    /**
+     * @return the framework's AsyncHttpClient-backed HTTP client factory, sharing the owning client's
+     *         event loop / timer / DNS resources; never {@code null}
+     */
+    PulsarHttpClientFactory httpClientFactory();
+
+    /**
+     * @return the framework-owned scheduler for delayed / periodic authentication work; never the Netty
+     *         event loop
+     */
+    ScheduledExecutorService scheduler();
+
+    /**
+     * @return the framework-owned bounded executor for off-loading potentially-blocking authentication
+     *         work (credential I/O, legacy v4 plugin calls); never the Netty event loop
+     */
+    Executor blockingExecutor();
+
+    /**
+     * @return the clock implementations schedule against
+     */
+    Clock clock();
+
+    /**
+     * @return the telemetry root ({@link OpenTelemetry#noop()} when unset)
+     */
+    OpenTelemetry openTelemetry();
+
+    /**
+     * @return a stable id of the owning {@code PulsarClient}, for logging correlation
+     */
+    String clientInstanceId();
+}
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/internal/ClientAuthenticationServicesAware.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/internal/ClientAuthenticationServicesAware.java
new file mode 100644
index 000000000000..a55561b8ab99
--- /dev/null
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/internal/ClientAuthenticationServicesAware.java
@@ -0,0 +1,47 @@
+/*
+ * 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.pulsar.client.api.v5.internal;
+
+/**
+ * Implemented by an authentication driver that wants the framework's real client services late-bound
+ * into it after the {@code PulsarClient} is constructed (PIP-478).
+ *
+ * <p>The client calls {@link #bindClientAuthenticationServices} exactly once, on
+ * {@code conf.getAuthentication()}, <em>before</em> {@code Authentication.start()} — so the bound
+ * services (real scheduler, bounded blocking executor, HTTP client factory, client instance id) are
+ * visible to the plugin's {@code initializeAsync(...)} and to every capability call thereafter. A driver
+ * that is never bound (a plain v4 plugin, or one used outside a client) simply keeps its unbound
+ * behaviour: credential work runs inline on the caller thread rather than being off-loaded.
+ *
+ * <p>Both the built-in v4 auth shims (which drive a v5-native body) and the v5&hairsp;&rarr;&hairsp;v4
+ * bridge ({@code V5ToV4AuthenticationAdapter}, exposing a genuinely v5-native plugin) implement this so
+ * that the same late-binding path serves the v4-client and v5-client cases uniformly.
+ *
+ * <p>The {@code .internal.} subpackage signals "stable internal" — application code should not implement
+ * this interface; it is observed and driven by the framework.
+ */
+public interface ClientAuthenticationServicesAware {
+
+    /**
+     * Bind the framework's client services into this driver. Called once, before the plugin is started.
+     *
+     * @param services the framework-owned client services
+     */
+    void bindClientAuthenticationServices(ClientAuthenticationServices services);
+}
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/internal/PulsarClientProvider.java b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/internal/PulsarClientProvider.java
index 9d3e41e2d125..6277f4745738 100644
--- a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/internal/PulsarClientProvider.java
+++ b/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/internal/PulsarClientProvider.java
@@ -99,7 +99,7 @@ public interface PulsarClientProvider {
 
     Authentication authenticationToken(Supplier<String> tokenSupplier);
 
-    Authentication authenticationTls(String certFilePath, String keyFilePath);
+    Authentication authenticationTls();
 
     Authentication createAuthentication(String className, String params) throws PulsarClientException;
 
diff --git a/pulsar-client-api-v5/src/test/java/org/apache/pulsar/client/api/v5/Examples.java b/pulsar-client-api-v5/src/test/java/org/apache/pulsar/client/api/v5/Examples.java
index 1218c8ca27c8..de8ad86bd254 100644
--- a/pulsar-client-api-v5/src/test/java/org/apache/pulsar/client/api/v5/Examples.java
+++ b/pulsar-client-api-v5/src/test/java/org/apache/pulsar/client/api/v5/Examples.java
@@ -40,8 +40,8 @@ import org.apache.pulsar.client.api.v5.config.MemorySize;
 import org.apache.pulsar.client.api.v5.config.ProcessingTimeoutPolicy;
 import org.apache.pulsar.client.api.v5.config.ProducerEncryptionPolicy;
 import org.apache.pulsar.client.api.v5.config.SubscriptionInitialPosition;
-import org.apache.pulsar.client.api.v5.config.TlsPolicy;
 import org.apache.pulsar.client.api.v5.schema.Schema;
+import org.apache.pulsar.tls.TlsPolicy;
 
 /**
  * Usage examples for the Pulsar v5 client API.
diff --git a/pulsar-client-api/src/main/java/org/apache/pulsar/client/api/ClientBuilder.java b/pulsar-client-api/src/main/java/org/apache/pulsar/client/api/ClientBuilder.java
index 64b8d60e4740..2966ca993bc4 100644
--- a/pulsar-client-api/src/main/java/org/apache/pulsar/client/api/ClientBuilder.java
+++ b/pulsar-client-api/src/main/java/org/apache/pulsar/client/api/ClientBuilder.java
@@ -739,18 +739,25 @@ public interface ClientBuilder extends Serializable, Cloneable {
     ClientBuilder socks5ProxyScope(Socks5ProxyScope socks5ProxyScope);
 
     /**
-     * Set the SSL Factory Plugin for custom implementation to create SSL Context and SSLEngine.
-     * @param sslFactoryPlugin ssl factory class name
+     * Set the class name of a custom {@code PulsarTlsFactory} (PIP-478) used to build the client's TLS
+     * engines. An empty value or the literal {@code "default"} selects the built-in file-based factory
+     * composed from the {@code tls*} settings; any other value is instantiated reflectively via its public
+     * no-arg constructor. This is the by-name successor of the removed PIP-337 {@code sslFactoryPlugin}.
+     *
+     * @param tlsFactoryClassName the {@code PulsarTlsFactory} class name (blank/{@code default} for built-in)
      * @return the client builder instance
      */
-    ClientBuilder sslFactoryPlugin(String sslFactoryPlugin);
+    ClientBuilder tlsFactoryClassName(String tlsFactoryClassName);
 
     /**
-     * Set the SSL Factory Plugin params for the ssl factory plugin to use.
-     * @param sslFactoryPluginParams Params in String format that will be inputted to the SSL Factory Plugin
+     * Set the configuration parameters passed to a custom {@link #tlsFactoryClassName(String)} as its init
+     * params (PIP-478). Accepts a JSON object or a comma-separated {@code key=value} list; ignored by the
+     * built-in file-based factory.
+     *
+     * @param tlsFactoryConfig the factory configuration (JSON object or {@code key=value} list)
      * @return the client builder instance
      */
-    ClientBuilder sslFactoryPluginParams(String sslFactoryPluginParams);
+    ClientBuilder tlsFactoryConfig(String tlsFactoryConfig);
 
     /**
      * Set Cert Refresh interval in seconds.
diff --git a/pulsar-client-api/src/main/java/org/apache/pulsar/client/api/internal/AsyncAuthenticationDriver.java b/pulsar-client-api/src/main/java/org/apache/pulsar/client/api/internal/AsyncAuthenticationDriver.java
new file mode 100644
index 000000000000..f903546fe64a
--- /dev/null
+++ b/pulsar-client-api/src/main/java/org/apache/pulsar/client/api/internal/AsyncAuthenticationDriver.java
@@ -0,0 +1,96 @@
+/*
+ * 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.pulsar.client.api.internal;
+
+import java.util.concurrent.CompletableFuture;
+import org.apache.pulsar.common.api.AuthData;
+
+/**
+ * Stable-internal marker that lets {@code ClientCnx} drive authentication asynchronously (PIP-478).
+ *
+ * <p>This is the only carve-out from the otherwise-untouched v4 client. When the configured
+ * {@link org.apache.pulsar.client.api.Authentication} instance also implements this interface,
+ * {@code ClientCnx} routes connect / refresh / challenge handling through these non-blocking methods
+ * and pipes the result back onto the Netty event loop; plain v4 instances keep the existing
+ * synchronous path verbatim. It is generic across all challenge types (initial connect, the broker's
+ * {@code REFRESH_AUTH_DATA} sentinel, SASL multi-round, and custom challenge/response).
+ *
+ * <p><b>Exchange-scoped.</b> Authentication is driven per <em>exchange</em> — one connection attempt.
+ * {@code ClientCnx} calls {@link #newAuthenticationExchange(String)} once per connect and drives that
+ * connection's rounds through the single returned {@link AuthenticationExchange} — the initial credential
+ * via {@link AuthenticationExchange#getAuthDataAsync()} and every ordinary challenge round (SASL
+ * multi-round, custom challenge/response) via {@link AuthenticationExchange#authenticateAsync(AuthData)}.
+ * The exchange is where per-connection conversation state lives (for the v5 bridge, one call-context and
+ * its state slot), so binding it to the exchange object — rather than passing the host on each call —
+ * makes the state slot's lifetime exactly one exchange. The broker's {@code REFRESH_AUTH_DATA} sentinel
+ * does <em>not</em> ride the current exchange: {@code ClientCnx} terminates it and starts a <em>fresh</em>
+ * exchange whose {@link AuthenticationExchange#getAuthDataAsync()} re-produces the current credential, so
+ * conversation state does NOT survive a REFRESH. A fresh exchange is likewise created for every connection
+ * attempt, so concurrent handshakes to different brokers never collide.
+ *
+ * <p>The {@code .internal.} subpackage signals "stable internal" — application code should not
+ * implement this interface; it is observed by the framework.
+ */
+public interface AsyncAuthenticationDriver {
+
+    /**
+     * Begin an authentication exchange for a single connection attempt to the given broker.
+     *
+     * <p>The returned {@link AuthenticationExchange} owns all state for this one connection setup and is
+     * driven by {@code ClientCnx} for every round of the handshake. Create a new exchange per connection
+     * attempt; do not share one across connections.
+     *
+     * @param brokerHostName the broker host being connected to (for per-host credentials)
+     * @return a new, single-use exchange scoped to this connection attempt
+     */
+    AuthenticationExchange newAuthenticationExchange(String brokerHostName);
+
+    /**
+     * A single authentication exchange: the credential rounds of one {@code ClientCnx} connection setup.
+     *
+     * <p>An exchange is <em>not</em> thread-safe and is not reused across connection attempts. Its rounds
+     * are serialized by the caller ({@code ClientCnx} issues the next round only after the previous
+     * future completes), so an implementation needs no internal synchronization for its per-exchange
+     * conversation state. Every future-returning method reports failures by completing the returned
+     * future exceptionally; it never throws on the calling thread.
+     */
+    interface AuthenticationExchange {
+
+        /**
+         * Asynchronously produce the initial authentication data for {@code CommandConnect}.
+         *
+         * @return a future of the auth data; completes exceptionally on failure
+         */
+        CompletableFuture<AuthData> getAuthDataAsync();
+
+        /**
+         * Asynchronously compute the response to a broker {@code CommandAuthChallenge} — an ordinary
+         * challenge round (SASL multi-round or custom challenge/response), answered from this exchange's
+         * conversation state.
+         *
+         * <p>The broker's {@code REFRESH_AUTH_DATA} sentinel is <em>not</em> delivered here: {@code ClientCnx}
+         * services a refresh by starting a fresh exchange and calling {@link #getAuthDataAsync()} on it, so
+         * in the {@code ClientCnx} flow this method only ever sees a genuine challenge payload.
+         *
+         * @param challenge the challenge payload from the broker (not the refresh sentinel)
+         * @return a future of the response auth data; completes exceptionally on failure
+         */
+        CompletableFuture<AuthData> authenticateAsync(AuthData challenge);
+    }
+}
diff --git a/pulsar-client-api-v5/build.gradle.kts b/pulsar-client-api/src/main/java/org/apache/pulsar/client/api/internal/package-info.java
similarity index 76%
copy from pulsar-client-api-v5/build.gradle.kts
copy to pulsar-client-api/src/main/java/org/apache/pulsar/client/api/internal/package-info.java
index 6b2323163684..3828c2ff7808 100644
--- a/pulsar-client-api-v5/build.gradle.kts
+++ b/pulsar-client-api/src/main/java/org/apache/pulsar/client/api/internal/package-info.java
@@ -17,11 +17,8 @@
  * under the License.
  */
 
-plugins {
-    id("pulsar.public-java-library-conventions")
-}
-
-dependencies {
-    compileOnly(libs.protobuf.java)
-    compileOnly(libs.opentelemetry.api)
-}
+/**
+ * Stable-internal client API types observed by the framework but not intended for application
+ * implementation. See {@link org.apache.pulsar.client.api.internal.AsyncAuthenticationDriver}.
+ */
+package org.apache.pulsar.client.api.internal;
diff --git a/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/CmdClusters.java b/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/CmdClusters.java
index 9f3aa68ca8c3..cb88366449db 100644
--- a/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/CmdClusters.java
+++ b/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/CmdClusters.java
@@ -365,13 +365,20 @@ public class CmdClusters extends CmdBase {
                 description = "path for the TLS certificate file", required = false)
         protected String brokerClientCertificateFilePath;
 
-        @Option(names = "--tls-factory-plugin",
-                description = "TLS Factory Plugin to be used to generate SSL Context and SSL Engine")
-        protected String brokerClientSslFactoryPlugin;
-
-        @Option(names = "--tls-factory-plugin-params",
-                description = "Parameters used by the TLS Factory Plugin")
-        protected String brokerClientSslFactoryPluginParams;
+        @Option(names = "--tls-factory-class-name",
+                description = "PulsarTlsFactory class name used for outbound connections to this cluster — the "
+                        + "two legs configured from this cluster entry, the binary-protocol replication client "
+                        + "and the cross-cluster admin client. It does not reach the peer-cluster lookup "
+                        + "client, which stays broker-level (as in 4.x). "
+                        + "Leave unset to inherit the broker's brokerClientTlsFactoryClassName.")
+        protected String brokerClientTlsFactoryClassName;
+
+        @Option(names = "--tls-factory-config",
+                description = "Configuration passed to --tls-factory-class-name as its init params, either a "
+                        + "JSON object or a key=value list. Follows --tls-factory-class-name rather than "
+                        + "inheriting on its own: used verbatim when this cluster names a factory, ignored "
+                        + "otherwise.")
+        protected String brokerClientTlsFactoryConfig;
 
         @Option(names = "--listener-name",
                 description = "listenerName when client would like to connect to cluster", required = false)
@@ -449,11 +456,13 @@ public class CmdClusters extends CmdBase {
             if (brokerClientCertificateFilePath != null) {
                 builder.brokerClientCertificateFilePath(brokerClientCertificateFilePath);
             }
-            if (StringUtils.isNotBlank(brokerClientSslFactoryPlugin)) {
-                builder.brokerClientSslFactoryPlugin(brokerClientSslFactoryPlugin);
+
+            if (brokerClientTlsFactoryClassName != null) {
+                builder.brokerClientTlsFactoryClassName(brokerClientTlsFactoryClassName);
             }
-            if (StringUtils.isNotBlank(brokerClientSslFactoryPluginParams)) {
-                builder.brokerClientSslFactoryPluginParams(brokerClientSslFactoryPluginParams);
+
+            if (brokerClientTlsFactoryConfig != null) {
+                builder.brokerClientTlsFactoryConfig(brokerClientTlsFactoryConfig);
             }
 
             if (listenerName != null) {
diff --git a/pulsar-client-tools/src/main/java/org/apache/pulsar/client/cli/PulsarClientTool.java b/pulsar-client-tools/src/main/java/org/apache/pulsar/client/cli/PulsarClientTool.java
index 481f8266ed09..7bbb54e4a596 100644
--- a/pulsar-client-tools/src/main/java/org/apache/pulsar/client/cli/PulsarClientTool.java
+++ b/pulsar-client-tools/src/main/java/org/apache/pulsar/client/cli/PulsarClientTool.java
@@ -34,9 +34,9 @@ import org.apache.pulsar.client.api.v5.PulsarClient;
 import org.apache.pulsar.client.api.v5.PulsarClientBuilder;
 import org.apache.pulsar.client.api.v5.config.ConnectionPolicy;
 import org.apache.pulsar.client.api.v5.config.MemorySize;
-import org.apache.pulsar.client.api.v5.config.TlsPolicy;
 import org.apache.pulsar.internal.CommandHook;
 import org.apache.pulsar.internal.CommanderFactory;
+import org.apache.pulsar.tls.TlsPolicy;
 import picocli.CommandLine;
 import picocli.CommandLine.ArgGroup;
 import picocli.CommandLine.Command;
@@ -207,38 +207,63 @@ public class PulsarClientTool implements CommandHook {
      * Translate the client.conf TLS settings onto the typed V5 {@link TlsPolicy}. V5 has no
      * untyped {@code loadConf}, so the conf-file keys that have no dedicated CLI flag
      * ({@code tlsAllowInsecureConnection}, {@code tlsEnableHostnameVerification}, the mTLS
-     * cert/key paths) are read from the properties here.
+     * cert/key paths, the {@code useKeyStoreTls} keystore keys) are read from the properties here.
      *
      * <p>TLS is enabled only when the service URL uses {@code pulsar+ssl://} or the conf sets
      * {@code useTls=true}; otherwise we leave the policy untouched so a plaintext broker is not
      * accidentally contacted over TLS (calling {@code tlsPolicy()} always flips {@code useTls}
-     * on). Keystore TLS has no V5 equivalent and is reported as unsupported.
+     * on). Per PIP-478 hostname verification defaults ON ({@link TlsPolicy}'s secure default);
+     * an explicit {@code tlsEnableHostnameVerification=false} in the conf still opts out. When
+     * {@code useKeyStoreTls=true}, the v4 keystore keys ({@code tlsTrustStorePath},
+     * {@code tlsKeyStorePath}, ...) map onto a {@link TlsPolicy.Format#KEYSTORE} policy instead
+     * of the PEM paths. The {@code jsseProvider} conf key is honored in both formats so a
+     * FIPS/provider-specific endpoint (e.g. {@code BCJSSE}) is not silently served by the
+     * platform provider.
      */
-    private void applyTlsPolicy(PulsarClientBuilder clientBuilder, String serviceUrl, Properties properties) {
+    @VisibleForTesting
+    void applyTlsPolicy(PulsarClientBuilder clientBuilder, String serviceUrl, Properties properties) {
         boolean tlsByUrl = serviceUrl != null && serviceUrl.startsWith("pulsar+ssl://");
         boolean tlsByConf = Boolean.parseBoolean(properties.getProperty("useTls", "false"));
         if (!tlsByUrl && !tlsByConf) {
             return;
         }
-        if (Boolean.parseBoolean(properties.getProperty("useKeyStoreTls", "false"))) {
-            commander.getErr().println("Warning: keystore TLS (useKeyStoreTls) is not supported by the "
-                    + "V5-based pulsar-client; PEM trust/cert/key settings are used instead.");
-        }
         TlsPolicy.Builder tls = TlsPolicy.builder()
                 .allowInsecureConnection(
-                        Boolean.parseBoolean(properties.getProperty("tlsAllowInsecureConnection", "false")))
-                .enableHostnameVerification(
-                        Boolean.parseBoolean(properties.getProperty("tlsEnableHostnameVerification", "false")));
-        if (isNotBlank(rootParams.tlsTrustCertsFilePath)) {
-            tls.trustCertsFilePath(rootParams.tlsTrustCertsFilePath);
+                        Boolean.parseBoolean(properties.getProperty("tlsAllowInsecureConnection", "false")));
+        // Only an explicit conf value may override the builder's secure default (verification ON);
+        // an absent key must not silently disable it.
+        String hostnameVerification = properties.getProperty("tlsEnableHostnameVerification");
+        if (hostnameVerification != null) {
+            tls.enableHostnameVerification(Boolean.parseBoolean(hostnameVerification));
         }
-        String certFile = properties.getProperty("tlsCertificateFilePath");
-        if (isNotBlank(certFile)) {
-            tls.certificateFilePath(certFile);
+        if (Boolean.parseBoolean(properties.getProperty("useKeyStoreTls", "false"))) {
+            // The store types default to JKS, matching the v4 client.conf semantics; TlsPolicy's
+            // null means KeyStore.getDefaultType() (PKCS12 on current JDKs), which would break
+            // existing confs that rely on the v4 JKS default.
+            tls.format(TlsPolicy.Format.KEYSTORE)
+                    .trustStorePath(properties.getProperty("tlsTrustStorePath"))
+                    .trustStorePassword(properties.getProperty("tlsTrustStorePassword"))
+                    .trustStoreType(properties.getProperty("tlsTrustStoreType", "JKS"))
+                    .keyStorePath(properties.getProperty("tlsKeyStorePath"))
+                    .keyStorePassword(properties.getProperty("tlsKeyStorePassword"))
+                    .keyStoreType(properties.getProperty("tlsKeyStoreType", "JKS"));
+        } else {
+            if (isNotBlank(rootParams.tlsTrustCertsFilePath)) {
+                tls.trustCertsFilePath(rootParams.tlsTrustCertsFilePath);
+            }
+            String certFile = properties.getProperty("tlsCertificateFilePath");
+            if (isNotBlank(certFile)) {
+                tls.certificateFilePath(certFile);
+            }
+            String keyFile = properties.getProperty("tlsKeyFilePath");
+            if (isNotBlank(keyFile)) {
+                tls.keyFilePath(keyFile);
+            }
         }
-        String keyFile = properties.getProperty("tlsKeyFilePath");
-        if (isNotBlank(keyFile)) {
-            tls.keyFilePath(keyFile);
+        // Format-independent: the JSSE (SSLContext) provider applies to both PEM and KEYSTORE.
+        String jsseProvider = properties.getProperty("jsseProvider");
+        if (isNotBlank(jsseProvider)) {
+            tls.jsseProvider(jsseProvider);
         }
         clientBuilder.tlsPolicy(tls.build());
     }
diff --git a/pulsar-client-tools/src/test/java/org/apache/pulsar/admin/cli/TestCmdClusters.java b/pulsar-client-tools/src/test/java/org/apache/pulsar/admin/cli/TestCmdClusters.java
index bdac206cc7f1..ed40aff3dbfa 100644
--- a/pulsar-client-tools/src/test/java/org/apache/pulsar/admin/cli/TestCmdClusters.java
+++ b/pulsar-client-tools/src/test/java/org/apache/pulsar/admin/cli/TestCmdClusters.java
@@ -96,6 +96,27 @@ public class TestCmdClusters {
                 .build();
     }
 
+    @Test
+    public void testTlsFactoryOptions() throws Exception {
+        // PIP-478: a cluster entry may select its own PulsarTlsFactory for outbound connections to that
+        // remote cluster — the two legs configured from the cluster entry, the binary replication client
+        // and the cross-cluster admin client (not the broker-level peer-cluster lookup client).
+        ClusterData expected = ClusterData.builder()
+                .brokerClientTlsFactoryClassName("com.example.MyTlsFactory")
+                .brokerClientTlsFactoryConfig("k1=v1,k2=v2")
+                .build();
+
+        cmdClusters.run(new String[]{"create", "test_cluster",
+                "--tls-factory-class-name", "com.example.MyTlsFactory",
+                "--tls-factory-config", "k1=v1,k2=v2"});
+        verify(clusters).createCluster(eq("test_cluster"), eq(expected));
+
+        cmdClusters.run(new String[]{"update", "test_cluster",
+                "--tls-factory-class-name", "com.example.MyTlsFactory",
+                "--tls-factory-config", "k1=v1,k2=v2"});
+        verify(clusters).updateCluster(eq("test_cluster"), eq(expected));
+    }
+
     @Test
     public void testListCmd() throws Exception {
         List<String> clusterList = Lists.newArrayList("us-west", "us-east", "us-cent");
diff --git a/pulsar-client-tools/src/test/java/org/apache/pulsar/client/cli/PulsarClientToolTest.java b/pulsar-client-tools/src/test/java/org/apache/pulsar/client/cli/PulsarClientToolTest.java
new file mode 100644
index 000000000000..9bb7f2d929a0
--- /dev/null
+++ b/pulsar-client-tools/src/test/java/org/apache/pulsar/client/cli/PulsarClientToolTest.java
@@ -0,0 +1,162 @@
+/*
+ * 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.pulsar.client.cli;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import java.util.Properties;
+import org.apache.pulsar.client.api.v5.PulsarClientBuilder;
+import org.apache.pulsar.tls.TlsPolicy;
+import org.mockito.ArgumentCaptor;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+/**
+ * Tests the client.conf → V5 {@link TlsPolicy} translation in
+ * {@link PulsarClientTool#applyTlsPolicy}.
+ */
+public class PulsarClientToolTest {
+
+    private static TlsPolicy capturePolicy(PulsarClientTool tool, String serviceUrl, Properties properties) {
+        PulsarClientBuilder builder = mock(PulsarClientBuilder.class);
+        tool.applyTlsPolicy(builder, serviceUrl, properties);
+        ArgumentCaptor<TlsPolicy> captor = ArgumentCaptor.forClass(TlsPolicy.class);
+        verify(builder).tlsPolicy(captor.capture());
+        return captor.getValue();
+    }
+
+    private static TlsPolicy capturePolicy(String serviceUrl, Properties properties) {
+        return capturePolicy(new PulsarClientTool(properties), serviceUrl, properties);
+    }
+
+    @Test
+    public void testHostnameVerificationDefaultsOnWhenConfKeyAbsent() {
+        // PIP-478 secure default: an absent tlsEnableHostnameVerification key must not disable
+        // the TlsPolicy builder's verification-ON default.
+        TlsPolicy policy = capturePolicy("pulsar+ssl://localhost:6651", new Properties());
+        assertThat(policy.enableHostnameVerification()).isTrue();
+        assertThat(policy.allowInsecureConnection()).isFalse();
+        assertThat(policy.format()).isEqualTo(TlsPolicy.Format.PEM);
+    }
+
+    @DataProvider(name = "hostnameVerificationValues")
+    public Object[][] hostnameVerificationValues() {
+        return new Object[][] {{"false", false}, {"true", true}};
+    }
+
+    @Test(dataProvider = "hostnameVerificationValues")
+    public void testExplicitHostnameVerificationValueWins(String confValue, boolean expected) {
+        Properties properties = new Properties();
+        properties.setProperty("useTls", "true");
+        properties.setProperty("tlsEnableHostnameVerification", confValue);
+        TlsPolicy policy = capturePolicy("pulsar://localhost:6650", properties);
+        assertThat(policy.enableHostnameVerification()).isEqualTo(expected);
+    }
+
+    @Test
+    public void testNoTlsPolicyForPlaintextUrl() {
+        PulsarClientBuilder builder = mock(PulsarClientBuilder.class);
+        Properties properties = new Properties();
+        new PulsarClientTool(properties).applyTlsPolicy(builder, "pulsar://localhost:6650", properties);
+        verify(builder, never()).tlsPolicy(any());
+    }
+
+    @Test
+    public void testPemBranchUsesConfCertAndKeyPaths() {
+        Properties properties = new Properties();
+        properties.setProperty("useTls", "true");
+        properties.setProperty("tlsCertificateFilePath", "/path/cert.pem");
+        properties.setProperty("tlsKeyFilePath", "/path/key.pem");
+        PulsarClientTool tool = new PulsarClientTool(properties);
+        tool.rootParams.tlsTrustCertsFilePath = "/path/ca.pem";
+        TlsPolicy policy = capturePolicy(tool, "pulsar://localhost:6650", properties);
+        assertThat(policy.format()).isEqualTo(TlsPolicy.Format.PEM);
+        assertThat(policy.trustCertsFilePath()).isEqualTo("/path/ca.pem");
+        assertThat(policy.certificateFilePath()).isEqualTo("/path/cert.pem");
+        assertThat(policy.keyFilePath()).isEqualTo("/path/key.pem");
+    }
+
+    @Test
+    public void testKeyStoreTlsTranslatesToKeystorePolicy() {
+        Properties properties = new Properties();
+        properties.setProperty("useTls", "true");
+        properties.setProperty("useKeyStoreTls", "true");
+        properties.setProperty("tlsTrustStorePath", "/path/truststore.p12");
+        properties.setProperty("tlsTrustStorePassword", "trust-pw");
+        properties.setProperty("tlsTrustStoreType", "PKCS12");
+        properties.setProperty("tlsKeyStorePath", "/path/keystore.p12");
+        properties.setProperty("tlsKeyStorePassword", "key-pw");
+        properties.setProperty("tlsKeyStoreType", "PKCS12");
+        properties.setProperty("tlsAllowInsecureConnection", "true");
+        properties.setProperty("tlsEnableHostnameVerification", "false");
+        TlsPolicy policy = capturePolicy("pulsar://localhost:6650", properties);
+        assertThat(policy.format()).isEqualTo(TlsPolicy.Format.KEYSTORE);
+        assertThat(policy.trustStorePath()).isEqualTo("/path/truststore.p12");
+        assertThat(policy.trustStorePassword()).isEqualTo("trust-pw");
+        assertThat(policy.trustStoreType()).isEqualTo("PKCS12");
+        assertThat(policy.keyStorePath()).isEqualTo("/path/keystore.p12");
+        assertThat(policy.keyStorePassword()).isEqualTo("key-pw");
+        assertThat(policy.keyStoreType()).isEqualTo("PKCS12");
+        assertThat(policy.allowInsecureConnection()).isTrue();
+        assertThat(policy.enableHostnameVerification()).isFalse();
+    }
+
+    @Test
+    public void testKeyStoreTypesDefaultToJksLikeV4() {
+        // v4 client.conf defaulted both store types to JKS when the keys were absent; the
+        // translation must preserve that (TlsPolicy's null means the JDK default type instead).
+        Properties properties = new Properties();
+        properties.setProperty("useTls", "true");
+        properties.setProperty("useKeyStoreTls", "true");
+        properties.setProperty("tlsTrustStorePath", "/path/truststore.jks");
+        properties.setProperty("tlsTrustStorePassword", "trust-pw");
+        TlsPolicy policy = capturePolicy("pulsar://localhost:6650", properties);
+        assertThat(policy.format()).isEqualTo(TlsPolicy.Format.KEYSTORE);
+        assertThat(policy.trustStoreType()).isEqualTo("JKS");
+        assertThat(policy.keyStoreType()).isEqualTo("JKS");
+        assertThat(policy.enableHostnameVerification()).isTrue();
+    }
+
+    @Test
+    public void testJsseProviderConfKeyIsHonored() {
+        // A FIPS/provider-specific endpoint relies on the jsseProvider conf key reaching the
+        // typed policy; without it the CLI would silently use the platform provider.
+        Properties properties = new Properties();
+        properties.setProperty("useTls", "true");
+        properties.setProperty("jsseProvider", "BCJSSE");
+        TlsPolicy policy = capturePolicy("pulsar://localhost:6650", properties);
+        assertThat(policy.jsseProvider()).isEqualTo("BCJSSE");
+    }
+
+    @Test
+    public void testJsseProviderConfKeyIsHonoredForKeystoreFormat() {
+        // jsseProvider is format-independent: it must reach the policy in the KEYSTORE branch too.
+        Properties properties = new Properties();
+        properties.setProperty("useTls", "true");
+        properties.setProperty("useKeyStoreTls", "true");
+        properties.setProperty("tlsTrustStorePath", "/path/truststore.p12");
+        properties.setProperty("jsseProvider", "BCJSSE");
+        TlsPolicy policy = capturePolicy("pulsar://localhost:6650", properties);
+        assertThat(policy.format()).isEqualTo(TlsPolicy.Format.KEYSTORE);
+        assertThat(policy.jsseProvider()).isEqualTo("BCJSSE");
+    }
+}
diff --git a/pulsar-client-v5/build.gradle.kts b/pulsar-client-v5/build.gradle.kts
index 8e564ca4593f..3d27cf8e2da3 100644
--- a/pulsar-client-v5/build.gradle.kts
+++ b/pulsar-client-v5/build.gradle.kts
@@ -23,6 +23,8 @@ plugins {
 
 dependencies {
     api(project(":pulsar-client-api-v5"))
+    implementation(project(":pulsar-tls-factory-api"))
+    implementation(project(":pulsar-http-client-api"))
     implementation(project(":pulsar-client-original"))
     implementation(project(":pulsar-common"))
     implementation(libs.slf4j.api)
diff --git a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/AuthenticationAdapter.java b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/AuthenticationAdapter.java
deleted file mode 100644
index bb51d2f9107e..000000000000
--- a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/AuthenticationAdapter.java
+++ /dev/null
@@ -1,202 +0,0 @@
-/*
- * 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.pulsar.client.impl.v5;
-
-import java.security.PrivateKey;
-import java.security.cert.Certificate;
-import java.util.Map;
-import java.util.Set;
-import java.util.function.Supplier;
-import org.apache.pulsar.client.api.AuthenticationDataProvider;
-import org.apache.pulsar.client.api.v5.PulsarClientException;
-import org.apache.pulsar.client.api.v5.auth.Authentication;
-import org.apache.pulsar.client.api.v5.auth.AuthenticationData;
-import org.apache.pulsar.client.impl.auth.AuthenticationTls;
-import org.apache.pulsar.client.impl.auth.AuthenticationToken;
-
-/**
- * Adapts between v5 Authentication and v4 Authentication interfaces.
- */
-final class AuthenticationAdapter {
-
-    private AuthenticationAdapter() {
-    }
-
-    /**
-     * Create a v5 Authentication wrapping a v4 token auth.
-     */
-    static Authentication token(String token) {
-        return new V5AuthWrapper(new AuthenticationToken(token));
-    }
-
-    /**
-     * Create a v5 Authentication wrapping a v4 token supplier auth.
-     */
-    static Authentication token(Supplier<String> tokenSupplier) {
-        return new V5AuthWrapper(new AuthenticationToken(tokenSupplier));
-    }
-
-    /**
-     * Create a v5 Authentication wrapping a v4 TLS auth.
-     */
-    static Authentication tls(String certFilePath, String keyFilePath) {
-        return new V5AuthWrapper(new AuthenticationTls(certFilePath, keyFilePath));
-    }
-
-    /**
-     * Create a v5 Authentication by loading a v4 auth plugin by class name.
-     */
-    static Authentication create(String className, String params) throws PulsarClientException {
-        try {
-            var v4Auth = org.apache.pulsar.client.api.AuthenticationFactory.create(className, params);
-            return new V5AuthWrapper(v4Auth);
-        } catch (org.apache.pulsar.client.api.PulsarClientException e) {
-            throw new PulsarClientException(e.getMessage(), e);
-        }
-    }
-
-    /**
-     * Create a v5 Authentication by loading a v4 auth plugin by class name and params map.
-     */
-    static Authentication create(String className, Map<String, String> params)
-            throws PulsarClientException {
-        try {
-            var v4Auth = org.apache.pulsar.client.api.AuthenticationFactory.create(className, params);
-            return new V5AuthWrapper(v4Auth);
-        } catch (org.apache.pulsar.client.api.PulsarClientException e) {
-            throw new PulsarClientException(e.getMessage(), e);
-        }
-    }
-
-    /**
-     * Unwrap a v5 Authentication to get the v4 Authentication.
-     */
-    static org.apache.pulsar.client.api.Authentication toV4(Authentication v5Auth) {
-        if (v5Auth instanceof V5AuthWrapper wrapper) {
-            return wrapper.v4Auth;
-        }
-        throw new IllegalArgumentException("Unknown v5 Authentication type: " + v5Auth.getClass());
-    }
-
-    /**
-     * V5 Authentication that wraps a v4 Authentication.
-     */
-    private static final class V5AuthWrapper implements Authentication {
-        final org.apache.pulsar.client.api.Authentication v4Auth;
-
-        V5AuthWrapper(org.apache.pulsar.client.api.Authentication v4Auth) {
-            this.v4Auth = v4Auth;
-        }
-
-        @Override
-        public String authMethodName() {
-            return v4Auth.getAuthMethodName();
-        }
-
-        @Override
-        public AuthenticationData authData() throws PulsarClientException {
-            try {
-                AuthenticationDataProvider v4Data = v4Auth.getAuthData();
-                return v4Data == null ? null : new V5AuthDataWrapper(v4Data);
-            } catch (org.apache.pulsar.client.api.PulsarClientException e) {
-                throw new PulsarClientException(e.getMessage(), e);
-            }
-        }
-
-        @Override
-        public AuthenticationData authData(String brokerHostName) throws PulsarClientException {
-            try {
-                AuthenticationDataProvider v4Data = v4Auth.getAuthData(brokerHostName);
-                return v4Data == null ? null : new V5AuthDataWrapper(v4Data);
-            } catch (org.apache.pulsar.client.api.PulsarClientException e) {
-                throw new PulsarClientException(e.getMessage(), e);
-            }
-        }
-
-        @Override
-        public void initialize() throws PulsarClientException {
-            try {
-                v4Auth.start();
-            } catch (org.apache.pulsar.client.api.PulsarClientException e) {
-                throw new PulsarClientException(e.getMessage(), e);
-            }
-        }
-
-        @Override
-        public void close() {
-            try {
-                v4Auth.close();
-            } catch (Exception e) {
-                // ignore
-            }
-        }
-    }
-
-    /**
-     * Bridges a v4 {@link AuthenticationDataProvider} into the V5 {@link AuthenticationData}
-     * surface. The two interfaces are intentionally aligned so this is a 1:1 delegation.
-     */
-    private static final class V5AuthDataWrapper implements AuthenticationData {
-        private final AuthenticationDataProvider v4Data;
-
-        V5AuthDataWrapper(AuthenticationDataProvider v4Data) {
-            this.v4Data = v4Data;
-        }
-
-        @Override
-        public boolean hasDataForHttp() {
-            return v4Data.hasDataForHttp();
-        }
-
-        @Override
-        public Set<Map.Entry<String, String>> getHttpHeaders() {
-            try {
-                Set<Map.Entry<String, String>> headers = v4Data.getHttpHeaders();
-                return headers != null ? headers : Set.of();
-            } catch (Exception e) {
-                throw new RuntimeException("Failed to obtain HTTP auth headers", e);
-            }
-        }
-
-        @Override
-        public boolean hasDataForTls() {
-            return v4Data.hasDataForTls();
-        }
-
-        @Override
-        public Certificate[] getTlsCertificates() {
-            return v4Data.getTlsCertificates();
-        }
-
-        @Override
-        public PrivateKey getTlsPrivateKey() {
-            return v4Data.getTlsPrivateKey();
-        }
-
-        @Override
-        public boolean hasDataFromCommand() {
-            return v4Data.hasDataFromCommand();
-        }
-
-        @Override
-        public String getCommandData() {
-            return v4Data.getCommandData();
-        }
-    }
-}
diff --git a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/PulsarClientBuilderV5.java b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/PulsarClientBuilderV5.java
index 05dba76c00d1..9117ea2b11c4 100644
--- a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/PulsarClientBuilderV5.java
+++ b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/PulsarClientBuilderV5.java
@@ -20,16 +20,30 @@ package org.apache.pulsar.client.impl.v5;
 
 import io.opentelemetry.api.OpenTelemetry;
 import java.time.Duration;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import org.apache.pulsar.client.api.AuthenticationDataProvider;
+import org.apache.pulsar.client.api.KeyStoreParams;
 import org.apache.pulsar.client.api.v5.PulsarClient;
 import org.apache.pulsar.client.api.v5.PulsarClientBuilder;
 import org.apache.pulsar.client.api.v5.PulsarClientException;
 import org.apache.pulsar.client.api.v5.auth.Authentication;
 import org.apache.pulsar.client.api.v5.config.ConnectionPolicy;
 import org.apache.pulsar.client.api.v5.config.MemorySize;
-import org.apache.pulsar.client.api.v5.config.TlsPolicy;
 import org.apache.pulsar.client.api.v5.config.TransactionPolicy;
 import org.apache.pulsar.client.impl.PulsarClientImpl;
+import org.apache.pulsar.client.impl.auth.AuthenticationDisabled;
+import org.apache.pulsar.client.impl.auth.AuthenticationKeyStoreTls;
+import org.apache.pulsar.client.impl.auth.AuthenticationTls;
+import org.apache.pulsar.client.impl.auth.v5.LegacyV4AuthenticationAdapter;
+import org.apache.pulsar.client.impl.auth.v5.V5AuthenticationLoader;
 import org.apache.pulsar.client.impl.conf.ClientConfigurationData;
+import org.apache.pulsar.client.impl.v5.auth.V5ToV4AuthenticationAdapter;
+import org.apache.pulsar.tls.PulsarTlsFactory;
+import org.apache.pulsar.tls.TlsPolicy;
+import org.apache.pulsar.tls.TlsPurpose;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * V5 implementation of PulsarClientBuilder.
@@ -37,9 +51,15 @@ import org.apache.pulsar.client.impl.conf.ClientConfigurationData;
  */
 final class PulsarClientBuilderV5 implements PulsarClientBuilder {
 
+    private static final Logger LOG = LoggerFactory.getLogger(PulsarClientBuilderV5.class);
+
     private final ClientConfigurationData conf = new ClientConfigurationData();
     private String description;
     private Duration transactionTimeout;
+    // The configured v5 authentication, resolved into the v4 conf.authentication at build() (so a bridged
+    // v4 plugin can be probed on the application thread — off the event loop — to fold its TLS material and
+    // to decide whether it must stay wrapped for credential off-loading). Null when no auth is configured.
+    private Authentication v5Authentication;
 
     PulsarClientBuilderV5() {
         conf.setStatsIntervalSeconds(0);
@@ -52,6 +72,7 @@ final class PulsarClientBuilderV5 implements PulsarClientBuilder {
     @Override
     public PulsarClient build() throws PulsarClientException {
         try {
+            applyAuthentication();
             var v4Client = new PulsarClientImpl(conf);
             return new PulsarClientV5(v4Client, description, transactionTimeout);
         } catch (org.apache.pulsar.client.api.PulsarClientException e) {
@@ -68,28 +89,158 @@ final class PulsarClientBuilderV5 implements PulsarClientBuilder {
 
     @Override
     public PulsarClientBuilder authentication(Authentication authentication) {
-        conf.setAuthentication(AuthenticationAdapter.toV4(authentication));
+        this.v5Authentication = authentication;
         return this;
     }
 
     @Override
     public PulsarClientBuilder authentication(String authPluginClassName, String authParamsString)
             throws PulsarClientException {
+        // Preserve the serializable string form (authPluginClassName + authParams) AND eagerly build the
+        // plugin, mirroring the v4 ClientBuilderImpl. The build is v5-aware (PIP-478 In-Scope #2): a
+        // v5-native plugin deployed by class name is instantiated + configured, instead of being blind-cast
+        // to the v4 Authentication SPI (which threw ClassCastException). A legacy v4 class keeps the
+        // existing v4 path. The resolution to the v4 conf.authentication happens at build().
         conf.setAuthPluginClassName(authPluginClassName);
         conf.setAuthParams(authParamsString);
-        // Instantiate the Authentication and attach it to the conf — the v4 PulsarClientImpl
-        // honors the plugin class name + params strings only as metadata, it reads the actual
-        // Authentication instance via conf.getAuthentication() at connect time.
         try {
-            conf.setAuthentication(
-                    org.apache.pulsar.client.api.AuthenticationFactory.create(
-                            authPluginClassName, authParamsString));
-        } catch (org.apache.pulsar.client.api.PulsarClientException.UnsupportedAuthenticationException e) {
+            this.v5Authentication = V5AuthenticationLoader.create(authPluginClassName, authParamsString);
+        } catch (org.apache.pulsar.client.api.PulsarClientException e) {
             throw new PulsarClientException(e.getMessage(), e);
         }
         return this;
     }
 
+    /**
+     * Resolve the configured v5 {@link Authentication} into the v4 {@code Authentication} the underlying
+     * {@link PulsarClientImpl} consumes, folding any bridged v4 TLS material into the client TLS policy and
+     * choosing how the plugin is driven. Runs at {@link #build()} on the application thread — off the Netty
+     * event loop — where probing a bridged plugin for its material is safe.
+     *
+     * <p>A bridged v4 plugin (from {@code AuthenticationFactory.token/tls/create}) that performs <b>no
+     * credential I/O</b> — the built-in TLS-material plugins (their material is folded into the client TLS
+     * configuration; the binary handshake carries an empty payload) and disabled auth — is driven raw on the
+     * v4 client. A credential-fetching v4 plugin, and every genuinely v5-native plugin, is exposed through
+     * {@link V5ToV4AuthenticationAdapter} so its (possibly blocking) {@code getAuthData} off-loads to the
+     * blocking executor instead of running on the Netty event loop (PIP-478: v4 credential calls are
+     * ALWAYS off-loaded). The adapter implements {@code ClientAuthenticationServicesAware}, so the client
+     * late-binds real executors / HTTP client factory / client instance id into it before {@code start()}
+     * (PIP-478).
+     */
+    private void applyAuthentication() {
+        if (v5Authentication == null) {
+            return;
+        }
+        org.apache.pulsar.client.api.Authentication bridgedV4 =
+                LegacyV4AuthenticationAdapter.unwrapV4(v5Authentication).orElse(null);
+        boolean runRaw = bridgedV4 != null && resolveBridgedV4(bridgedV4);
+        conf.setAuthentication(runRaw
+                ? bridgedV4
+                : new V5ToV4AuthenticationAdapter(v5Authentication));
+    }
+
+    /**
+     * Resolve the configured authentication and return the v4 {@code Authentication} the client would drive.
+     * Package-private for tests that assert the unwrap-vs-offload decision without standing up a broker
+     * (VisibleForTesting; Guava's annotation is not on this module's classpath).
+     *
+     * @return the resolved v4 authentication (a raw v4 engine, or a wrapping {@code V5ToV4AuthenticationAdapter})
+     */
+    org.apache.pulsar.client.api.Authentication resolveAuthenticationForTest() {
+        applyAuthentication();
+        return conf.getAuthentication();
+    }
+
+    /**
+     * For a bridged v4 plugin: fold its TLS material into {@link TlsPurpose#CLIENT_DEFAULT} (when a
+     * {@code tlsPolicy} is configured) and decide whether it can be driven raw on the v4 client.
+     *
+     * @param v4 the bridged v4 plugin recovered from the wrapping adapter
+     * @return {@code true} when the raw v4 engine is correct — it performs no credential I/O (a built-in
+     *         TLS-material plugin or disabled auth); {@code false} when it fetches a command/HTTP credential
+     *         and must stay wrapped so the call off-loads
+     */
+    private boolean resolveBridgedV4(org.apache.pulsar.client.api.Authentication v4) {
+        boolean foldTls = conf.getTlsPolicyMap() != null;
+        if (v4 instanceof AuthenticationTls tls) {
+            if (foldTls && tls.getCertFilePath() != null && tls.getKeyFilePath() != null) {
+                mergeClientDefault(base -> pemBuilder(base)
+                        .certificateFilePath(tls.getCertFilePath())
+                        .keyFilePath(tls.getKeyFilePath())
+                        .build());
+            }
+            return true;
+        }
+        if (v4 instanceof AuthenticationKeyStoreTls keyStoreTls) {
+            if (foldTls && keyStoreTls.getKeyStoreParams() != null) {
+                KeyStoreParams ks = keyStoreTls.getKeyStoreParams();
+                mergeClientDefault(base -> keyStoreBuilder(base)
+                        .keyStorePath(ks.getKeyStorePath())
+                        .keyStorePassword(ks.getKeyStorePassword())
+                        .keyStoreType(ks.getKeyStoreType())
+                        .build());
+            }
+            return true;
+        }
+        if (v4 instanceof AuthenticationDisabled) {
+            return true;
+        }
+        return resolveGenericV4(v4, foldTls);
+    }
+
+    /**
+     * Probe a generic (non-built-in) bridged v4 plugin once at build time (application thread, off the event
+     * loop): fold any file-based TLS material into {@link TlsPurpose#CLIENT_DEFAULT} when a {@code tlsPolicy}
+     * is configured, and report whether it can run raw. A plugin that exposes only in-memory cert/key
+     * material is logged rather than silently dropped (it cannot be represented in the file-path
+     * {@link TlsPolicy}).
+     *
+     * @param v4      the bridged v4 authentication plugin (not a built-in TLS class)
+     * @param foldTls whether a {@code tlsPolicy} is configured, so TLS material should be folded
+     * @return {@code true} when the plugin must be driven raw on the v4 client — it performs no credential
+     *         I/O, or it carries TLS material that can only reach the transport through the legacy path;
+     *         {@code false} when it should stay wrapped for credential off-load
+     */
+    @SuppressWarnings("deprecation")
+    private boolean resolveGenericV4(org.apache.pulsar.client.api.Authentication v4, boolean foldTls) {
+        final AuthenticationDataProvider data;
+        try {
+            data = v4.getAuthData();
+        } catch (Exception e) {
+            // A plugin that cannot produce auth data at build time (e.g. a not-yet-reachable credential
+            // endpoint) contributes no TLS material here; keep it wrapped so any later credential I/O
+            // off-loads instead of running on the event loop.
+            LOG.debug("Could not probe v4 authentication plugin {} at build time", v4.getClass().getName(), e);
+            return false;
+        }
+        if (data == null) {
+            return false;
+        }
+        boolean hasCredentialIo = data.hasDataFromCommand() || data.hasDataForHttp();
+        if (data.hasDataForTls()) {
+            if (foldTls) {
+                // tlsPolicy is configured: fold the plugin's file material into the client TLS factory path,
+                // so the transport reads TLS from the factory and the plugin may still be wrapped for
+                // credential off-load.
+                foldGenericV4TlsMaterial(data, v4.getClass().getName());
+                return !hasCredentialIo;
+            }
+            // No tlsPolicy: the legacy v4 TLS path reads the plugin's material directly from getAuthData(),
+            // so it MUST run raw or its client certificate would be dropped (the wrapping adapter's
+            // synthesized v4 provider carries only the binary credential, not TLS material). A plugin that
+            // also fetches a credential therefore forgoes off-load here — the two cannot both hold on the
+            // legacy path; configuring tlsPolicy(...) restores off-load by moving TLS to the factory path.
+            if (hasCredentialIo) {
+                LOG.warn("Bridged v4 authentication plugin {} provides both TLS material and a fetched "
+                        + "credential but no tlsPolicy(...) is configured; it runs inline (no credential "
+                        + "off-load) so its client certificate is still presented on the legacy TLS path. "
+                        + "Configure tlsPolicy(...) to off-load the credential.", v4.getClass().getName());
+            }
+            return true;
+        }
+        return !hasCredentialIo;
+    }
+
     @Override
     public PulsarClientBuilder operationTimeout(Duration timeout) {
         conf.setOperationTimeoutMs(timeout.toMillis());
@@ -127,21 +278,140 @@ final class PulsarClientBuilderV5 implements PulsarClientBuilder {
 
     @Override
     public PulsarClientBuilder tlsPolicy(TlsPolicy policy) {
-        conf.setUseTls(true);
-        if (policy.trustCertsFilePath() != null) {
-            conf.setTlsTrustCertsFilePath(policy.trustCertsFilePath());
+        return tlsPolicy(TlsPurpose.CLIENT_DEFAULT, policy);
+    }
+
+    @Override
+    public PulsarClientBuilder tlsPolicy(TlsPurpose purpose, TlsPolicy policy) {
+        if (purpose == null || policy == null) {
+            throw new IllegalArgumentException("tlsPolicy purpose and policy must not be null");
         }
-        if (policy.keyFilePath() != null) {
-            conf.setTlsKeyFilePath(policy.keyFilePath());
+        // useTls governs the BINARY BROKER TRANSPORT only, so a policy for a different trust domain must not
+        // turn it on. Configuring CLIENT_OAUTH2 (the identity provider) against a plaintext pulsar:// broker
+        // is a legitimate combination — it was enabling TLS toward the broker and failing the connection.
+        // The policy map itself is what triggers TLS-factory creation, so a non-transport purpose still gets
+        // its factory without touching the transport.
+        if (purpose.role() == TlsPurpose.Role.CLIENT && !TlsPurpose.CLIENT_OAUTH2.equals(purpose)) {
+            conf.setUseTls(true);
         }
-        if (policy.certificateFilePath() != null) {
-            conf.setTlsCertificateFilePath(policy.certificateFilePath());
+        Map<TlsPurpose, TlsPolicy> map = conf.getTlsPolicyMap();
+        if (map == null) {
+            map = new LinkedHashMap<>();
+            conf.setTlsPolicyMap(map);
         }
-        conf.setTlsAllowInsecureConnection(policy.allowInsecureConnection());
-        conf.setTlsHostnameVerificationEnable(policy.enableHostnameVerification());
+        map.put(purpose, policy);
         return this;
     }
 
+    @Override
+    public PulsarClientBuilder tlsFactory(PulsarTlsFactory factory) {
+        if (factory == null) {
+            throw new IllegalArgumentException("tlsFactory must not be null");
+        }
+        conf.setUseTls(true);
+        conf.setTlsFactory(factory);
+        return this;
+    }
+
+    /**
+     * Fold a bridged third-party v4 plugin's file-based TLS material into {@link TlsPurpose#CLIENT_DEFAULT}
+     * (PIP-478). The plugin's {@code getAuthData()} has already been probed by
+     * {@link #resolveGenericV4} on the application thread (off the event loop), and {@code data} is known to
+     * report {@code hasDataForTls()}. Only <em>file-based</em> material (PEM cert/key file paths or a
+     * keystore) can be represented in the file-path {@link TlsPolicy}; a plugin that exposes only in-memory
+     * cert/key material is logged rather than silently dropped, since it cannot be folded on this path.
+     *
+     * @param data       the already-probed auth data reporting TLS material
+     * @param pluginName the bridged plugin's class name, for logging
+     */
+    @SuppressWarnings("deprecation")
+    private void foldGenericV4TlsMaterial(AuthenticationDataProvider data, String pluginName) {
+        String certPath = data.getTlsCertificateFilePath();
+        String keyPath = data.getTlsPrivateKeyFilePath();
+        KeyStoreParams ks = data.getTlsKeyStoreParams();
+        if (isNotBlank(certPath) && isNotBlank(keyPath)) {
+            mergeClientDefault(base -> pemBuilder(base).certificateFilePath(certPath).keyFilePath(keyPath).build());
+        } else if (ks != null && isNotBlank(ks.getKeyStorePath())) {
+            mergeClientDefault(base -> keyStoreBuilder(base)
+                    .keyStorePath(ks.getKeyStorePath())
+                    .keyStorePassword(ks.getKeyStorePassword())
+                    .keyStoreType(ks.getKeyStoreType())
+                    .build());
+        } else {
+            LOG.warn("Bridged v4 authentication plugin {} reports TLS material (hasDataForTls()) but exposes "
+                    + "only in-memory cert/key, which cannot be represented in the file-path client TLS "
+                    + "policy; its material will not be folded into the transport. Configure it via "
+                    + "tlsPolicy(...) or a file-based plugin.", pluginName);
+        }
+    }
+
+    private static boolean isNotBlank(String s) {
+        return s != null && !s.isBlank();
+    }
+
+    private void mergeClientDefault(java.util.function.Function<TlsPolicy, TlsPolicy> merge) {
+        Map<TlsPurpose, TlsPolicy> map = conf.getTlsPolicyMap();
+        TlsPolicy base = map.get(TlsPurpose.CLIENT_DEFAULT);
+        map.put(TlsPurpose.CLIENT_DEFAULT, merge.apply(base));
+    }
+
+    /** Copy the trust material and flags of {@code base} (if any) into a PEM-format builder. */
+    private static TlsPolicy.Builder pemBuilder(TlsPolicy base) {
+        TlsPolicy.Builder b = copyFlags(base).format(TlsPolicy.Format.PEM);
+        if (base != null && base.format() == TlsPolicy.Format.PEM) {
+            b.trustCertsFilePath(base.trustCertsFilePath());
+        } else if (base != null && isNotBlank(base.trustStorePath())) {
+            // Cross-format fold: the tlsPolicy(...) carries a keystore truststore but the auth plugin's client
+            // identity is PEM. A PEM policy has no truststore field, so folding here would silently drop the
+            // configured trust anchors and fall back to the system trust store. Fail loud (matching
+            // TlsPolicy.build()'s fail-loud format validation) rather than silently broadening/breaking trust.
+            throw new IllegalArgumentException("Cross-format TLS material: tlsPolicy(...) configures a keystore "
+                    + "truststore (trustStorePath) but the authentication plugin supplies a PEM client "
+                    + "certificate/key. Folding these would silently drop the configured truststore. Configure the "
+                    + "trust material and the client identity in the same format (both PEM, or both keystore).");
+        }
+        return b;
+    }
+
+    /** Copy the trust material and flags of {@code base} (if any) into a keystore-format builder. */
+    private static TlsPolicy.Builder keyStoreBuilder(TlsPolicy base) {
+        TlsPolicy.Builder b = copyFlags(base).format(TlsPolicy.Format.KEYSTORE);
+        if (base != null && base.format() == TlsPolicy.Format.KEYSTORE) {
+            // Preserve the base truststore (path, password, and TYPE): folding the auth plugin's keystore must
+            // not clobber the truststore type configured via tlsPolicy(...) — the keystore and truststore may
+            // use different types (e.g. a PKCS12 keystore with a JKS truststore).
+            b.trustStorePath(base.trustStorePath())
+                    .trustStorePassword(base.trustStorePassword())
+                    .trustStoreType(base.trustStoreType());
+        } else if (base != null && isNotBlank(base.trustCertsFilePath())) {
+            // Cross-format fold: the tlsPolicy(...) carries a PEM truststore (trustCertsFilePath) but the auth
+            // plugin's client identity is a keystore. A keystore policy has no PEM trust field, so folding here
+            // would silently drop the configured trust anchors and fall back to the system trust store. Fail loud
+            // (matching TlsPolicy.build()'s fail-loud format validation) rather than silently broadening/breaking
+            // trust.
+            throw new IllegalArgumentException("Cross-format TLS material: tlsPolicy(...) configures a PEM "
+                    + "truststore (trustCertsFilePath) but the authentication plugin supplies a keystore client "
+                    + "certificate/key. Folding these would silently drop the configured truststore. Configure the "
+                    + "trust material and the client identity in the same format (both PEM, or both keystore).");
+        }
+        return b;
+    }
+
+    private static TlsPolicy.Builder copyFlags(TlsPolicy base) {
+        TlsPolicy.Builder b = TlsPolicy.builder();
+        if (base != null) {
+            b.allowInsecureConnection(base.allowInsecureConnection())
+                    .enableHostnameVerification(base.enableHostnameVerification())
+                    .protocols(base.protocols())
+                    .ciphers(base.ciphers())
+                    // Preserve the pinned JSSE (SSLContext) provider across the fold. A FIPS deployment
+                    // pins it via tlsPolicy(...); dropping it here would let the transport fall back to
+                    // the default JDK engine, silently defeating the pin.
+                    .jsseProvider(base.jsseProvider());
+        }
+        return b;
+    }
+
     @Override
     public PulsarClientBuilder openTelemetry(OpenTelemetry openTelemetry) {
         conf.setOpenTelemetry(openTelemetry);
diff --git a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/PulsarClientProviderV5.java b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/PulsarClientProviderV5.java
index fe8ac87a70d3..96c8c3361c84 100644
--- a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/PulsarClientProviderV5.java
+++ b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/PulsarClientProviderV5.java
@@ -29,6 +29,10 @@ import org.apache.pulsar.client.api.v5.auth.Authentication;
 import org.apache.pulsar.client.api.v5.internal.PulsarClientProvider;
 import org.apache.pulsar.client.api.v5.schema.GenericRecord;
 import org.apache.pulsar.client.api.v5.schema.Schema;
+import org.apache.pulsar.client.impl.auth.AuthenticationToken;
+import org.apache.pulsar.client.impl.auth.v5.LegacyV4AuthenticationAdapter;
+import org.apache.pulsar.client.impl.auth.v5.TlsAuthentication;
+import org.apache.pulsar.client.impl.auth.v5.V5AuthenticationLoader;
 
 /**
  * ServiceLoader-registered implementation of PulsarClientProvider.
@@ -168,28 +172,36 @@ public final class PulsarClientProviderV5 implements PulsarClientProvider {
 
     @Override
     public Authentication authenticationToken(String token) {
-        return AuthenticationAdapter.token(token);
+        return LegacyV4AuthenticationAdapter.wrap(new AuthenticationToken(token));
     }
 
     @Override
     public Authentication authenticationToken(Supplier<String> tokenSupplier) {
-        return AuthenticationAdapter.token(tokenSupplier);
+        return LegacyV4AuthenticationAdapter.wrap(new AuthenticationToken(tokenSupplier));
     }
 
     @Override
-    public Authentication authenticationTls(String certFilePath, String keyFilePath) {
-        return AuthenticationAdapter.tls(certFilePath, keyFilePath);
+    public Authentication authenticationTls() {
+        return new TlsAuthentication();
     }
 
     @Override
     public Authentication createAuthentication(String className, String params)
             throws PulsarClientException {
-        return AuthenticationAdapter.create(className, params);
+        try {
+            return V5AuthenticationLoader.create(className, params);
+        } catch (org.apache.pulsar.client.api.PulsarClientException e) {
+            throw new PulsarClientException(e.getMessage(), e);
+        }
     }
 
     @Override
     public Authentication createAuthentication(String className, Map<String, String> params)
             throws PulsarClientException {
-        return AuthenticationAdapter.create(className, params);
+        try {
+            return V5AuthenticationLoader.create(className, params);
+        } catch (org.apache.pulsar.client.api.PulsarClientException e) {
+            throw new PulsarClientException(e.getMessage(), e);
+        }
     }
 }
diff --git a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/auth/SimpleAuthCallContext.java b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/auth/SimpleAuthCallContext.java
new file mode 100644
index 000000000000..d94975ea3b92
--- /dev/null
+++ b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/auth/SimpleAuthCallContext.java
@@ -0,0 +1,57 @@
+/*
+ * 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.pulsar.client.impl.v5.auth;
+
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import org.apache.pulsar.client.api.v5.auth.AuthenticationCallContext;
+
+/**
+ * Simple {@link AuthenticationCallContext} implementation backed by a concurrent map of typed
+ * per-exchange state objects. Used by the v5&harr;v4 bridges to drive challenge/response exchanges.
+ */
+class SimpleAuthCallContext implements AuthenticationCallContext {
+
+    private final String brokerHost;
+    private final Map<Class<?>, Object> state = new ConcurrentHashMap<>();
+
+    SimpleAuthCallContext(String brokerHost) {
+        this.brokerHost = brokerHost;
+    }
+
+    @Override
+    public String brokerHost() {
+        return brokerHost;
+    }
+
+    @Override
+    public <T> Optional<T> getStateObject(Class<T> clazz) {
+        return Optional.ofNullable(clazz.cast(state.get(clazz)));
+    }
+
+    @Override
+    public <T> void setStateObject(Class<T> clazz, T value) {
+        if (value == null) {
+            state.remove(clazz);
+        } else {
+            state.put(clazz, value);
+        }
+    }
+}
diff --git a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/auth/SimpleAuthInitContext.java b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/auth/SimpleAuthInitContext.java
new file mode 100644
index 000000000000..55456c8257d5
--- /dev/null
+++ b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/auth/SimpleAuthInitContext.java
@@ -0,0 +1,80 @@
+/*
+ * 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.pulsar.client.impl.v5.auth;
+
+import io.opentelemetry.api.OpenTelemetry;
+import java.time.Clock;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ScheduledExecutorService;
+import org.apache.pulsar.client.api.v5.auth.AuthenticationInitContext;
+import org.apache.pulsar.http.PulsarHttpClientFactory;
+
+/**
+ * Simple immutable {@link AuthenticationInitContext} implementation used by the v5/v4 bridges to
+ * initialize a v5 {@link org.apache.pulsar.client.api.v5.auth.Authentication} plugin.
+ */
+class SimpleAuthInitContext implements AuthenticationInitContext {
+
+    private final PulsarHttpClientFactory httpClientFactory;
+    private final ScheduledExecutorService scheduler;
+    private final Executor blockingExecutor;
+    private final Clock clock;
+    private final OpenTelemetry openTelemetry;
+    private final String clientInstanceId;
+
+    SimpleAuthInitContext(PulsarHttpClientFactory httpClientFactory, ScheduledExecutorService scheduler,
+            Executor blockingExecutor, Clock clock, OpenTelemetry openTelemetry, String clientInstanceId) {
+        this.httpClientFactory = httpClientFactory;
+        this.scheduler = scheduler;
+        this.blockingExecutor = blockingExecutor;
+        this.clock = clock;
+        this.openTelemetry = openTelemetry;
+        this.clientInstanceId = clientInstanceId;
+    }
+
+    @Override
+    public PulsarHttpClientFactory httpClientFactory() {
+        return httpClientFactory;
+    }
+
+    @Override
+    public ScheduledExecutorService scheduler() {
+        return scheduler;
+    }
+
+    @Override
+    public Executor blockingExecutor() {
+        return blockingExecutor;
+    }
+
+    @Override
+    public Clock clock() {
+        return clock;
+    }
+
+    @Override
+    public OpenTelemetry openTelemetry() {
+        return openTelemetry;
+    }
+
+    @Override
+    public String clientInstanceId() {
+        return clientInstanceId;
+    }
+}
diff --git a/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/auth/V5ToV4AuthenticationAdapter.java b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/auth/V5ToV4AuthenticationAdapter.java
new file mode 100644
index 000000000000..8d1a6cc8e102
--- /dev/null
+++ b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/auth/V5ToV4AuthenticationAdapter.java
@@ -0,0 +1,351 @@
+/*
+ * 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.pulsar.client.impl.v5.auth;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import io.opentelemetry.api.OpenTelemetry;
+import java.io.IOException;
+import java.io.NotSerializableException;
+import java.io.ObjectOutputStream;
+import java.net.URI;
+import java.time.Clock;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
+import org.apache.pulsar.client.api.AuthenticationDataProvider;
+import org.apache.pulsar.client.api.PulsarClientException;
+import org.apache.pulsar.client.api.internal.AsyncAuthenticationDriver;
+import org.apache.pulsar.client.api.v5.auth.Authentication;
+import org.apache.pulsar.client.api.v5.auth.AuthenticationCallContext;
+import org.apache.pulsar.client.api.v5.auth.AuthenticationInitContext;
+import org.apache.pulsar.client.api.v5.auth.BinaryAuthDataProvider;
+import org.apache.pulsar.client.api.v5.auth.HttpAuthCallContext;
+import org.apache.pulsar.client.api.v5.auth.HttpAuthHeaders;
+import org.apache.pulsar.client.api.v5.auth.HttpAuthHeadersProvider;
+import org.apache.pulsar.client.api.v5.internal.ClientAuthenticationServices;
+import org.apache.pulsar.client.api.v5.internal.ClientAuthenticationServicesAware;
+import org.apache.pulsar.client.impl.auth.v5.AuthMetrics;
+import org.apache.pulsar.client.impl.auth.v5.BinaryAuthenticationExchange;
+import org.apache.pulsar.common.api.AuthData;
+
+/**
+ * Exposes a new-SPI v5 {@link Authentication} plugin through the legacy v4
+ * {@link org.apache.pulsar.client.api.Authentication} interface that {@code ClientCnx} drives
+ * (PIP-478).
+ *
+ * <p>It also implements {@link AsyncAuthenticationDriver} so that the {@code ClientCnx} carve-out
+ * (PIP-478) can authenticate via the asynchronous (non event-loop-blocking) path. Both the
+ * async driver and the synchronous v4 {@link #getAuthData(String)} path are exchange-scoped: one
+ * {@link AsyncAuthenticationDriver.AuthenticationExchange} — and one {@link AuthenticationCallContext}
+ * with its state slot — backs a single connection attempt, so a plugin's challenge/response
+ * conversation state survives across all of that connection's rounds (initial data, refresh, and every
+ * {@code CommandAuthChallenge}).
+ *
+ * <p>The per-connection routing (initial connect / refresh sentinel / challenge) and the v5→v4 exception
+ * mapping are the shared {@link BinaryAuthenticationExchange} — the same implementation the built-in
+ * shims' {@code V5BinaryAuthenticationDriver} uses — so the two adapters cannot drift on the normative
+ * binary rules. Binary transport requires the {@link BinaryAuthDataProvider} capability (PIP-478 binary
+ * routing rule 1); a wrapped v5 plugin that does not expose it fails loudly — {@link #start()} throws a
+ * v4-mapped {@link PulsarClientException.UnsupportedAuthenticationException} — rather than synthesizing
+ * an empty {@code "none"} credential.
+ */
+// Implements the deprecated v4 Authentication SPI by design (configure(Map)).
+@SuppressWarnings("deprecation")
+public class V5ToV4AuthenticationAdapter
+        implements org.apache.pulsar.client.api.Authentication, AsyncAuthenticationDriver,
+        ClientAuthenticationServicesAware {
+
+    private static final long serialVersionUID = 1L;
+
+    private final transient Authentication v5;
+    // Late-bound by the client via bindClientAuthenticationServices(...) before start() (PIP-478);
+    // null until then (e.g. when the adapter is exercised outside a client), yielding an init context with
+    // no framework services.
+    private transient volatile ClientAuthenticationServices services;
+    // Built from the bound services' OpenTelemetry in start(); NOOP until then.
+    private transient volatile AuthMetrics authMetrics = AuthMetrics.NOOP;
+
+    /**
+     * Create an adapter that exposes a v5 authentication plugin through the v4 interface. The framework
+     * services the plugin's init context reports are late-bound by the client through
+     * {@link #bindClientAuthenticationServices} before {@link #start()}.
+     *
+     * @param v5 the v5 authentication plugin to expose
+     */
+    public V5ToV4AuthenticationAdapter(Authentication v5) {
+        this.v5 = v5;
+    }
+
+    @Override
+    public void bindClientAuthenticationServices(ClientAuthenticationServices services) {
+        this.services = services;
+    }
+
+    @Override
+    public String getAuthMethodName() {
+        return requireBinaryProvider().authMethodName();
+    }
+
+    @Override
+    public void configure(Map<String, String> authParams) {
+        v5.configure(authParams);
+    }
+
+    @Override
+    public void start() throws PulsarClientException {
+        ClientAuthenticationServices bound = this.services;
+        this.authMetrics = AuthMetrics.create(bound == null ? null : bound.openTelemetry());
+        AuthenticationInitContext initContext = bound == null
+                ? new SimpleAuthInitContext(null, null, null, Clock.systemUTC(), OpenTelemetry.noop(), null)
+                : new SimpleAuthInitContext(bound.httpClientFactory(), bound.scheduler(), bound.blockingExecutor(),
+                        bound.clock() == null ? Clock.systemUTC() : bound.clock(),
+                        bound.openTelemetry() == null ? OpenTelemetry.noop() : bound.openTelemetry(),
+                        bound.clientInstanceId());
+        try {
+            v5.initializeAsync(initContext).get();
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new PulsarClientException(e);
+        } catch (Exception e) {
+            throw BinaryAuthenticationExchange.toV4Exception(BinaryAuthenticationExchange.unwrap(e));
+        }
+        // Binary transport requires the BinaryAuthDataProvider capability (PIP-478 binary routing
+        // rule 1). Fail loudly here rather than synthesizing a "none"/empty credential.
+        if (v5.capability(BinaryAuthDataProvider.class).isEmpty()) {
+            throw new PulsarClientException.UnsupportedAuthenticationException(
+                    "v5 authentication plugin " + v5.getClass().getName() + " does not expose "
+                            + "BinaryAuthDataProvider; the Pulsar binary transport requires it (PIP-478)");
+        }
+    }
+
+    @Override
+    public AuthenticationDataProvider getAuthData(String brokerHostName) throws PulsarClientException {
+        AuthenticationExchange exchange = newAuthenticationExchange(brokerHostName);
+        try {
+            AuthData initial = exchange.getAuthDataAsync().get();
+            return new SynthesizedV4DataProvider(exchange, initial, v5, brokerHostName);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new PulsarClientException(e);
+        } catch (Exception e) {
+            throw BinaryAuthenticationExchange.toV4Exception(BinaryAuthenticationExchange.unwrap(e));
+        }
+    }
+
+    @Override
+    public AuthenticationExchange newAuthenticationExchange(String brokerHostName) {
+        return new BinaryAuthenticationExchange(v5, new SimpleAuthCallContext(brokerHostName), authMetrics);
+    }
+
+    /**
+     * The v5 {@link BinaryAuthDataProvider} the wrapped plugin exposes for the binary transport.
+     *
+     * @return the capability
+     * @throws IllegalStateException if the plugin does not support the binary transport; normally
+     *         {@link #start()} has already failed loudly before this can be reached
+     */
+    private BinaryAuthDataProvider requireBinaryProvider() {
+        return v5.capability(BinaryAuthDataProvider.class)
+                .orElseThrow(() -> new IllegalStateException(
+                        "v5 authentication plugin " + v5.getClass().getName() + " does not expose "
+                                + "BinaryAuthDataProvider; it cannot authenticate a Pulsar binary-protocol "
+                                + "connection (PIP-478)"));
+    }
+
+    @Override
+    public void close() throws IOException {
+        try {
+            v5.close();
+        } catch (IOException e) {
+            throw e;
+        } catch (Exception e) {
+            throw new IOException(e);
+        }
+    }
+
+    /**
+     * v5 {@link Authentication} plugins are not serializable. Serialization of this adapter is therefore
+     * unsupported. Configure authentication on a remote/forked context via the
+     * {@code authPluginClassName} + {@code authParams} string form instead.
+     *
+     * @param out the object output stream
+     * @throws NotSerializableException always
+     */
+    private void writeObject(ObjectOutputStream out) throws NotSerializableException {
+        throw new NotSerializableException("V5 authentication plugins are not serializable. "
+                + "Configure authentication using authPluginClassName + authParams instead of a "
+                + "pre-built Authentication instance.");
+    }
+
+    private static boolean isInitSentinel(AuthData challenge) {
+        return challenge != null && Arrays.equals(challenge.getBytes(), AuthData.INIT_AUTH_DATA_BYTES);
+    }
+
+    /**
+     * A v4 {@link AuthenticationDataProvider} synthesized from a single
+     * {@link AsyncAuthenticationDriver.AuthenticationExchange}, so that the synchronous v4 {@code ClientCnx}
+     * code path keeps working — including multi-round challenge/response, which rides
+     * {@link #authenticate(AuthData)} through the same exchange (and thus the same
+     * {@link AuthenticationCallContext} state slot).
+     *
+     * <p>It also bridges the single-pass HTTP transport: the v4 HTTP lookup path (which drives
+     * {@link #hasDataForHttp()} / {@link #getHttpHeaders()}) is served from the wrapped plugin's v5
+     * {@link HttpAuthHeadersProvider} capability, so a bearer/role credential (for example
+     * {@code Authorization: Bearer <jwt>}) is attached to the lookup {@code GET}. Multi-round
+     * SASL-over-HTTP challenge/response is out of scope here — it rides the framework HTTP challenge
+     * driver, not this bridge — so a plugin that exposes only an
+     * {@link org.apache.pulsar.client.api.v5.auth.HttpAuthChallengeHandler} contributes no static header
+     * and {@link #hasDataForHttp()} stays {@code false}.
+     */
+    private static final class SynthesizedV4DataProvider implements AuthenticationDataProvider {
+
+        private static final long serialVersionUID = 1L;
+
+        private final transient AuthenticationExchange exchange;
+        private final transient byte[] initialData;
+        private final transient Authentication v5;
+        private final transient String brokerHostName;
+
+        SynthesizedV4DataProvider(AuthenticationExchange exchange, AuthData initial, Authentication v5,
+                                  String brokerHostName) {
+            this.exchange = exchange;
+            this.initialData = initial == null || initial.getBytes() == null ? null : initial.getBytes();
+            this.v5 = v5;
+            this.brokerHostName = brokerHostName;
+        }
+
+        @Override
+        public boolean hasDataFromCommand() {
+            return initialData != null;
+        }
+
+        @Override
+        public String getCommandData() {
+            return initialData == null ? null : new String(initialData, UTF_8);
+        }
+
+        @Override
+        public boolean hasDataForHttp() {
+            return v5.capability(HttpAuthHeadersProvider.class).isPresent();
+        }
+
+        @Override
+        public Set<Map.Entry<String, String>> getHttpHeaders() throws Exception {
+            Optional<HttpAuthHeadersProvider> provider = v5.capability(HttpAuthHeadersProvider.class);
+            if (provider.isEmpty()) {
+                return null;
+            }
+            // Blocking .get() is intentional: this v4 hook runs on the client's blockingAuthExecutor
+            // (HttpClient.computeAuthHeaders offloads the whole v4 branch), never the event loop.
+            HttpAuthCallContext ctx = new SynthesizedHttpAuthCallContext(brokerHostName);
+            try {
+                HttpAuthHeaders headers = provider.get().getHttpHeadersAsync(ctx).get();
+                return headers == null || headers.isEmpty() ? null : headers.asMap().entrySet();
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                throw e;
+            } catch (ExecutionException e) {
+                Throwable cause = BinaryAuthenticationExchange.unwrap(e);
+                if (cause instanceof Exception ex) {
+                    throw ex;
+                }
+                throw new PulsarClientException(cause);
+            }
+        }
+
+        /**
+         * Route a challenge (or the connect/refresh sentinel) through the bound exchange so that v5
+         * challenge handlers work through the plain synchronous v4 path, sharing the exchange's state
+         * slot across rounds. The init sentinel reuses the already-computed initial credential to avoid
+         * re-running the initial round (which, for challenge/response plugins, would re-seed the
+         * conversation).
+         *
+         * @param data the challenge payload, or the {@code INIT}/{@code REFRESH} sentinel
+         * @return the response auth data
+         * @throws javax.naming.AuthenticationException if the exchange fails
+         */
+        @Override
+        public AuthData authenticate(AuthData data) throws javax.naming.AuthenticationException {
+            if (isInitSentinel(data)) {
+                return AuthData.of(initialData == null ? new byte[0] : initialData);
+            }
+            try {
+                AuthData result = exchange.authenticateAsync(data).get();
+                return AuthData.of(result == null ? new byte[0] : result.getBytes());
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                throw namingAuthException(e);
+            } catch (ExecutionException e) {
+                throw namingAuthException(BinaryAuthenticationExchange.unwrap(e));
+            }
+        }
+
+        private static javax.naming.AuthenticationException namingAuthException(Throwable cause) {
+            javax.naming.AuthenticationException e = new javax.naming.AuthenticationException(
+                    cause == null ? null : cause.getMessage());
+            e.initCause(cause);
+            return e;
+        }
+    }
+
+    /**
+     * A single-pass {@link HttpAuthCallContext} for the v4 HTTP lookup path, which surfaces only the
+     * request host (from {@link org.apache.pulsar.client.api.Authentication#getAuthData(String)}) rather
+     * than a full request URI. The host is presented as an {@code https://<host>} URI; single-pass
+     * {@link HttpAuthHeadersProvider}s (token/Athenz/OAuth2) do not consult it. There is no prior server
+     * challenge on this path, so {@link #serverChallengeHeaders()} is always empty; the state slot exists
+     * to satisfy the contract but is not shared across requests.
+     */
+    private static final class SynthesizedHttpAuthCallContext implements HttpAuthCallContext {
+
+        private final URI requestUri;
+        private final Map<Class<?>, Object> state = new ConcurrentHashMap<>();
+
+        SynthesizedHttpAuthCallContext(String brokerHostName) {
+            this.requestUri = URI.create("https://" + (brokerHostName == null ? "" : brokerHostName));
+        }
+
+        @Override
+        public URI requestUri() {
+            return requestUri;
+        }
+
+        @Override
+        public Optional<HttpAuthHeaders> serverChallengeHeaders() {
+            return Optional.empty();
+        }
+
+        @Override
+        public <T> Optional<T> getStateObject(Class<T> clazz) {
+            return Optional.ofNullable(clazz.cast(state.get(clazz)));
+        }
+
+        @Override
+        public <T> void setStateObject(Class<T> clazz, T value) {
+            if (value == null) {
+                state.remove(clazz);
+            } else {
+                state.put(clazz, value);
+            }
+        }
+    }
+}
diff --git a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/config/package-info.java b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/auth/package-info.java
similarity index 59%
copy from pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/config/package-info.java
copy to pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/auth/package-info.java
index afc47322a1c0..16d2a0e3b7db 100644
--- a/pulsar-client-api-v5/src/main/java/org/apache/pulsar/client/api/v5/config/package-info.java
+++ b/pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/auth/package-info.java
@@ -18,12 +18,12 @@
  */
 
 /**
- * Configuration value types and policy records.
+ * v5 client authentication bridges and the built-in mTLS plugin (PIP-478).
  *
- * <p>Immutable records used to group related configuration parameters in builder APIs,
- * such as {@link org.apache.pulsar.client.api.v5.config.BatchingPolicy},
- * {@link org.apache.pulsar.client.api.v5.config.BackoffPolicy},
- * {@link org.apache.pulsar.client.api.v5.config.TlsPolicy}, and
- * {@link org.apache.pulsar.client.api.v5.config.DeadLetterPolicy}.
+ * <p>Contains the built-in {@link org.apache.pulsar.client.impl.auth.v5.TlsAuthentication} plugin and
+ * the compatibility adapters between the v4 and v5 authentication SPIs:
+ * {@link org.apache.pulsar.client.impl.auth.v5.LegacyV4AuthenticationAdapter} (v4 plugin exposed as v5)
+ * and {@link org.apache.pulsar.client.impl.v5.auth.V5ToV4AuthenticationAdapter} (v5 plugin exposed
+ * through the v4 interface that {@code ClientCnx} drives).
  */
-package org.apache.pulsar.client.api.v5.config;
+package org.apache.pulsar.client.impl.v5.auth;
diff --git a/pulsar-client-v5/src/test/java/org/apache/pulsar/client/impl/v5/AuthenticationAdapterTest.java b/pulsar-client-v5/src/test/java/org/apache/pulsar/client/impl/v5/AuthenticationAdapterTest.java
deleted file mode 100644
index 283cff17b157..000000000000
--- a/pulsar-client-v5/src/test/java/org/apache/pulsar/client/impl/v5/AuthenticationAdapterTest.java
+++ /dev/null
@@ -1,62 +0,0 @@
-/*
- * 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.pulsar.client.impl.v5;
-
-import static org.testng.Assert.assertEquals;
-import static org.testng.Assert.assertNotNull;
-import static org.testng.Assert.assertTrue;
-import org.apache.pulsar.client.api.v5.auth.Authentication;
-import org.apache.pulsar.client.api.v5.auth.AuthenticationData;
-import org.testng.annotations.Test;
-
-/**
- * Verifies that {@code AuthenticationAdapter}'s V5 wrapper bridges through to the
- * v4 {@code AuthenticationDataProvider} on every dimension exposed by the V5
- * {@link AuthenticationData} surface — previously {@code authData()} was a TODO
- * stub returning {@code null}, which silently broke any V5 caller that asked the
- * wrapped auth for credentials.
- */
-public class AuthenticationAdapterTest {
-
-    @Test
-    public void testTokenAuthExposesCommandData() throws Exception {
-        Authentication v5Auth = AuthenticationAdapter.token("my-jwt");
-
-        AuthenticationData data = v5Auth.authData();
-        assertNotNull(data, "authData() must not return null after the wiring fix");
-        assertTrue(data.hasDataFromCommand(),
-                "token auth should expose its credential via the binary-protocol command path");
-        assertEquals(data.getCommandData(), "my-jwt");
-    }
-
-    @Test
-    public void testAuthMethodNamePassesThrough() throws Exception {
-        Authentication v5Auth = AuthenticationAdapter.token("any-token");
-        assertEquals(v5Auth.authMethodName(), "token");
-    }
-
-    @Test
-    public void testAuthDataPerBrokerHostDelegates() throws Exception {
-        Authentication v5Auth = AuthenticationAdapter.token("per-host-token");
-
-        AuthenticationData data = v5Auth.authData("broker-1.example.com");
-        assertNotNull(data, "authData(brokerHost) must not return null");
-        assertEquals(data.getCommandData(), "per-host-token");
-    }
-}
diff --git a/pulsar-client-v5/src/test/java/org/apache/pulsar/client/impl/v5/CredentialOffloadThroughV5BuilderTest.java b/pulsar-client-v5/src/test/java/org/apache/pulsar/client/impl/v5/CredentialOffloadThroughV5BuilderTest.java
new file mode 100644
index 000000000000..a8665c48d740
--- /dev/null
+++ b/pulsar-client-v5/src/test/java/org/apache/pulsar/client/impl/v5/CredentialOffloadThroughV5BuilderTest.java
@@ -0,0 +1,241 @@
+/*
+ * 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.pulsar.client.impl.v5;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.assertj.core.api.Assertions.assertThat;
+import io.opentelemetry.api.OpenTelemetry;
+import java.time.Clock;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.pulsar.client.api.AuthenticationDataProvider;
+import org.apache.pulsar.client.api.internal.AsyncAuthenticationDriver.AuthenticationExchange;
+import org.apache.pulsar.client.api.v5.internal.ClientAuthenticationServices;
+import org.apache.pulsar.client.impl.auth.v5.DefaultClientAuthenticationServices;
+import org.apache.pulsar.client.impl.auth.v5.LegacyV4AuthenticationAdapter;
+import org.apache.pulsar.client.impl.v5.auth.V5ToV4AuthenticationAdapter;
+import org.apache.pulsar.common.api.AuthData;
+import org.testng.annotations.Test;
+
+/**
+ * Verifies PIP-478: a bridged v4 credential plugin deployed on the v5 client keeps its async offload
+ * adapter (rather than being unwrapped onto the raw v4 engine), so its blocking {@code getAuthData} runs on
+ * the client's blocking executor — never inline on the Netty event loop. A TLS-only plugin, which performs
+ * no credential I/O, is driven raw.
+ */
+public class CredentialOffloadThroughV5BuilderTest {
+
+    private static final String BLOCKING_THREAD = "v5-builder-offload-test-thread";
+
+    @Test
+    public void credentialV4PluginStaysWrappedAndOffloadsGetAuthData() throws Exception {
+        BlockingCredentialV4 v4 = new BlockingCredentialV4();
+        PulsarClientBuilderV5 builder = new PulsarClientBuilderV5();
+        builder.authentication(LegacyV4AuthenticationAdapter.wrap(v4));
+
+        // Decision: the credential plugin is NOT unwrapped to the raw v4 engine; it stays wrapped so that
+        // its (blocking) getAuthData off-loads instead of running on the Netty event loop.
+        org.apache.pulsar.client.api.Authentication resolved = builder.resolveAuthenticationForTest();
+        assertThat(resolved).isInstanceOf(V5ToV4AuthenticationAdapter.class);
+
+        ExecutorService blocking = Executors.newSingleThreadExecutor(r -> {
+            Thread t = new Thread(r, BLOCKING_THREAD);
+            t.setDaemon(true);
+            return t;
+        });
+        ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
+        try {
+            V5ToV4AuthenticationAdapter adapter = (V5ToV4AuthenticationAdapter) resolved;
+            adapter.bindClientAuthenticationServices(services(blocking, scheduler));
+            adapter.start();
+
+            String callerThread = Thread.currentThread().getName();
+            AuthenticationExchange exchange = adapter.newAuthenticationExchange("broker.example.com");
+            CompletableFuture<AuthData> future = exchange.getAuthDataAsync();
+
+            // The fetch is off-loaded: it started on the blocking executor while the caller proceeds.
+            assertThat(v4.entered.await(5, SECONDS)).isTrue();
+            AuthData data = future.get(5, SECONDS);
+            assertThat(new String(data.getBytes(), UTF_8)).isEqualTo("the-credential");
+            assertThat(v4.fetchThread.get())
+                    .isEqualTo(BLOCKING_THREAD)
+                    .isNotEqualTo(callerThread);
+        } finally {
+            blocking.shutdownNow();
+            scheduler.shutdownNow();
+        }
+    }
+
+    @Test
+    public void tlsOnlyV4PluginRunsRawWithoutOffloadWrapper() {
+        // A plugin that reports TLS material but no command/HTTP credential performs no I/O to off-load, so
+        // it is driven raw on the v4 client (its TLS material is presented at the transport layer).
+        TlsOnlyV4 v4 = new TlsOnlyV4();
+        PulsarClientBuilderV5 builder = new PulsarClientBuilderV5();
+        builder.authentication(LegacyV4AuthenticationAdapter.wrap(v4));
+        assertThat(builder.resolveAuthenticationForTest()).isSameAs(v4);
+    }
+
+    @Test
+    public void combinedTlsAndCredentialV4WithoutTlsPolicyRunsRawToPreserveTls() {
+        // A v4 plugin that presents BOTH a client certificate (mTLS) and a fetched credential must run raw
+        // when no tlsPolicy is configured: the legacy TLS path reads its certificate from getAuthData(), so
+        // wrapping it for credential off-load would drop the certificate. Off-load is regained by
+        // configuring tlsPolicy(...) (which moves TLS material to the factory path).
+        CombinedTlsCredentialV4 v4 = new CombinedTlsCredentialV4();
+        PulsarClientBuilderV5 builder = new PulsarClientBuilderV5();
+        builder.authentication(LegacyV4AuthenticationAdapter.wrap(v4));
+        assertThat(builder.resolveAuthenticationForTest()).isSameAs(v4);
+    }
+
+    private static ClientAuthenticationServices services(ExecutorService blocking,
+            ScheduledExecutorService scheduler) {
+        // The offload path does not use HTTP, so a throwing factory suffices.
+        return new DefaultClientAuthenticationServices(config -> {
+            throw new UnsupportedOperationException("HTTP client not used in this test");
+        }, scheduler, blocking, Clock.systemUTC(), OpenTelemetry.noop(), "test-client");
+    }
+
+    /** A v4 credential plugin whose {@code getCommandData} blocks (models blocking credential I/O). */
+    @SuppressWarnings("deprecation")
+    private static final class BlockingCredentialV4 implements org.apache.pulsar.client.api.Authentication {
+
+        private final CountDownLatch entered = new CountDownLatch(1);
+        private final AtomicReference<String> fetchThread = new AtomicReference<>();
+
+        @Override
+        public String getAuthMethodName() {
+            return "blocking-cred";
+        }
+
+        @Override
+        public AuthenticationDataProvider getAuthData() {
+            return new AuthenticationDataProvider() {
+                @Override
+                public boolean hasDataFromCommand() {
+                    return true;
+                }
+
+                @Override
+                public String getCommandData() {
+                    fetchThread.set(Thread.currentThread().getName());
+                    entered.countDown();
+                    try {
+                        Thread.sleep(300);
+                    } catch (InterruptedException e) {
+                        Thread.currentThread().interrupt();
+                    }
+                    return "the-credential";
+                }
+            };
+        }
+
+        @Override
+        public void configure(Map<String, String> authParams) {
+        }
+
+        @Override
+        public void start() {
+        }
+
+        @Override
+        public void close() {
+        }
+    }
+
+    /** A v4 plugin that reports only TLS material (no command/HTTP credential) under a non-"tls" method name. */
+    @SuppressWarnings("deprecation")
+    private static final class TlsOnlyV4 implements org.apache.pulsar.client.api.Authentication {
+
+        @Override
+        public String getAuthMethodName() {
+            return "custom-tls";
+        }
+
+        @Override
+        public AuthenticationDataProvider getAuthData() {
+            return new AuthenticationDataProvider() {
+                @Override
+                public boolean hasDataForTls() {
+                    return true;
+                }
+            };
+        }
+
+        @Override
+        public void configure(Map<String, String> authParams) {
+        }
+
+        @Override
+        public void start() {
+        }
+
+        @Override
+        public void close() {
+        }
+    }
+
+    /** A v4 plugin that reports BOTH a client certificate (mTLS) and a command credential. */
+    @SuppressWarnings("deprecation")
+    private static final class CombinedTlsCredentialV4 implements org.apache.pulsar.client.api.Authentication {
+
+        @Override
+        public String getAuthMethodName() {
+            return "combined";
+        }
+
+        @Override
+        public AuthenticationDataProvider getAuthData() {
+            return new AuthenticationDataProvider() {
+                @Override
+                public boolean hasDataForTls() {
+                    return true;
+                }
+
+                @Override
+                public boolean hasDataFromCommand() {
+                    return true;
+                }
+
+                @Override
+                public String getCommandData() {
+                    return "the-credential";
+                }
+            };
+        }
+
+        @Override
+        public void configure(Map<String, String> authParams) {
+        }
+
+        @Override
+        public void start() {
+        }
+
+        @Override
+        public void close() {
+        }
+    }
+}
diff --git a/pulsar-client-v5/src/test/java/org/apache/pulsar/client/impl/v5/PulsarClientBuilderV5Test.java b/pulsar-client-v5/src/test/java/org/apache/pulsar/client/impl/v5/PulsarClientBuilderV5Test.java
index c081f6df6a05..f3bea25c252e 100644
--- a/pulsar-client-v5/src/test/java/org/apache/pulsar/client/impl/v5/PulsarClientBuilderV5Test.java
+++ b/pulsar-client-v5/src/test/java/org/apache/pulsar/client/impl/v5/PulsarClientBuilderV5Test.java
@@ -24,12 +24,17 @@ import static org.testng.Assert.assertNotNull;
 import static org.testng.Assert.assertThrows;
 import static org.testng.Assert.assertTrue;
 import static org.testng.Assert.fail;
+import java.util.Map;
+import org.apache.pulsar.client.api.AuthenticationDataProvider;
 import org.apache.pulsar.client.api.v5.PulsarClient;
 import org.apache.pulsar.client.api.v5.PulsarClientBuilder;
 import org.apache.pulsar.client.api.v5.PulsarClientException;
+import org.apache.pulsar.client.api.v5.auth.Authentication;
 import org.apache.pulsar.client.api.v5.config.ConnectionPolicy;
-import org.apache.pulsar.client.api.v5.config.TlsPolicy;
+import org.apache.pulsar.client.impl.auth.v5.LegacyV4AuthenticationAdapter;
 import org.apache.pulsar.client.impl.conf.ClientConfigurationData;
+import org.apache.pulsar.tls.TlsPolicy;
+import org.apache.pulsar.tls.TlsPurpose;
 import org.testng.annotations.Test;
 
 /**
@@ -101,10 +106,103 @@ public class PulsarClientBuilderV5Test {
                 "error must name the offending field: " + e.getMessage());
     }
 
+    /**
+     * PIP-478 stage 3b: on the v5-builder TLS path a bad {@link TlsPolicy} (here a non-existent trust
+     * cert file) fails the client build fast — at {@code build()} time, before any connection is
+     * attempted — with an actionable error, rather than surfacing later as an opaque handshake failure.
+     */
+    @Test
+    public void testTlsPolicyBadPathFailsClientBuild() {
+        String badPath = "/nonexistent/pip478/ca-does-not-exist.pem";
+        PulsarClientException e = null;
+        try {
+            PulsarClient.builder()
+                    .serviceUrl("pulsar+ssl://localhost:6651")
+                    .tlsPolicy(TlsPolicy.pem(badPath, null, null))
+                    .build();
+            fail("expected the client build to fail fast on the bad TLS policy");
+        } catch (PulsarClientException ex) {
+            e = ex;
+        }
+        assertNotNull(e, "a bad TLS policy must fail the client build");
+        assertTrue(allMessages(e).toLowerCase().contains("tls"),
+                "the failure must be actionable and mention TLS: " + allMessages(e));
+    }
+
+    /**
+     * PIP-478 stage 3c: a bridged third-party v4 plugin (not a built-in TLS class) that reports
+     * {@code hasDataForTls()} with file-based cert/key must have that material folded into
+     * {@link TlsPurpose#CLIENT_DEFAULT} at build time. Proven here by pointing the plugin at a non-existent
+     * cert file: the fold makes the client build fail fast on the missing file; without the fold the
+     * system-default CLIENT_DEFAULT policy would build cleanly.
+     */
+    @Test
+    public void genericV4TlsFilePluginMaterialIsFoldedIntoClientDefault() {
+        AuthenticationDataProvider data = new AuthenticationDataProvider() {
+            @Override
+            public boolean hasDataForTls() {
+                return true;
+            }
+
+            @Override
+            public String getTlsCertificateFilePath() {
+                return "/nonexistent/pip478/generic-cert.pem";
+            }
+
+            @Override
+            public String getTlsPrivateKeyFilePath() {
+                return "/nonexistent/pip478/generic-key.pem";
+            }
+        };
+        Authentication v5 = LegacyV4AuthenticationAdapter.wrap(new GenericTlsV4Auth("custom-tls", data));
+
+        PulsarClientException e = null;
+        try {
+            PulsarClient.builder()
+                    .serviceUrl("pulsar+ssl://localhost:6651")
+                    .authentication(v5)
+                    .tlsPolicy(TlsPolicy.pem(null, null, null))
+                    .build();
+            fail("expected the client build to fail fast on the folded generic TLS material");
+        } catch (PulsarClientException ex) {
+            e = ex;
+        }
+        assertNotNull(e, "the folded generic cert path must fail the client build");
+        assertTrue(allMessages(e).toLowerCase().contains("tls"),
+                "the failure must be actionable and mention TLS: " + allMessages(e));
+    }
+
+    /**
+     * PIP-478 stage 3c: a generic v4 plugin exposing only in-memory TLS material cannot be represented in a
+     * file-path {@link TlsPolicy}; it is logged rather than folded, and the client build still succeeds
+     * (the material simply is not applied on this path).
+     */
+    @Test
+    public void genericV4InMemoryOnlyTlsMaterialDoesNotFailBuild() throws Exception {
+        AuthenticationDataProvider inMemoryOnly = new AuthenticationDataProvider() {
+            @Override
+            public boolean hasDataForTls() {
+                return true;
+            }
+            // No file paths and no keystore params — only (notional) in-memory material.
+        };
+        Authentication v5 = LegacyV4AuthenticationAdapter.wrap(new GenericTlsV4Auth("custom-tls", inMemoryOnly));
+
+        try (PulsarClient client = PulsarClient.builder()
+                .serviceUrl("pulsar+ssl://localhost:6651")
+                .authentication(v5)
+                .tlsPolicy(TlsPolicy.pem(null, null, null))
+                .build()) {
+            assertNotNull(client, "an in-memory-only generic TLS plugin must not fail the client build");
+        }
+    }
+
     @Test
     public void testTlsPolicyFieldsPropagate() {
-        // Build a fully-populated TlsPolicy and confirm every field lands on the underlying
-        // v4 ClientConfigurationData. Used to be a stub that only set useTls=true.
+        // Build a fully-populated TlsPolicy and confirm the policy — every field intact — lands on the
+        // underlying v4 ClientConfigurationData. Used to be a stub that only set useTls=true; under
+        // PIP-478 the policy rides conf.tlsPolicyMap under TlsPurpose.CLIENT_DEFAULT (consumed by the
+        // client TLS factory) instead of the legacy per-field TLS settings.
         PulsarClientBuilderV5 builder = new PulsarClientBuilderV5();
         TlsPolicy policy = TlsPolicy.builder()
                 .trustCertsFilePath("/path/to/ca.pem")
@@ -118,33 +216,38 @@ public class PulsarClientBuilderV5Test {
 
         ClientConfigurationData conf = builder.getConfForTesting();
         assertTrue(conf.isUseTls());
-        assertEquals(conf.getTlsTrustCertsFilePath(), "/path/to/ca.pem");
-        assertEquals(conf.getTlsKeyFilePath(), "/path/to/client.key");
-        assertEquals(conf.getTlsCertificateFilePath(), "/path/to/client.cert");
-        assertTrue(conf.isTlsAllowInsecureConnection());
-        assertFalse(conf.isTlsHostnameVerificationEnable());
+        TlsPolicy applied = conf.getTlsPolicyMap().get(TlsPurpose.CLIENT_DEFAULT);
+        assertNotNull(applied, "the policy must land on the conf under CLIENT_DEFAULT");
+        assertEquals(applied.trustCertsFilePath(), "/path/to/ca.pem");
+        assertEquals(applied.keyFilePath(), "/path/to/client.key");
+        assertEquals(applied.certificateFilePath(), "/path/to/client.cert");
+        assertTrue(applied.allowInsecureConnection());
+        assertFalse(applied.enableHostnameVerification());
     }
 
     @Test
     public void testTlsPolicyInsecureShortcut() {
-        // TlsPolicy.ofInsecure() is the dev convenience that disables verification.
+        // TlsPolicy.insecure() is the dev convenience that disables verification.
         PulsarClientBuilderV5 builder = new PulsarClientBuilderV5();
-        builder.tlsPolicy(TlsPolicy.ofInsecure());
+        builder.tlsPolicy(TlsPolicy.insecure());
 
         ClientConfigurationData conf = builder.getConfForTesting();
         assertTrue(conf.isUseTls());
-        assertTrue(conf.isTlsAllowInsecureConnection());
-        assertFalse(conf.isTlsHostnameVerificationEnable());
+        TlsPolicy applied = conf.getTlsPolicyMap().get(TlsPurpose.CLIENT_DEFAULT);
+        assertNotNull(applied, "the insecure policy must land on the conf under CLIENT_DEFAULT");
+        assertTrue(applied.allowInsecureConnection());
+        assertFalse(applied.enableHostnameVerification());
     }
 
     @Test
     public void testAuthenticationPluginAndParamsInstantiatesAuthentication() throws Exception {
         // Regression for a bug where authentication(plugin, params) only set the strings and
-        // never called conf.setAuthentication(...) with the instantiated Authentication object.
-        // PulsarClientImpl reads the Authentication instance via conf.getAuthentication() at
-        // connect time — without it, the client connects with no credentials and the broker
-        // rejects the handshake. Use the v4 AuthenticationDisabled stub which always exists on
-        // the classpath; we only care that *some* instance lands on the conf.
+        // never instantiated the plugin. PulsarClientImpl reads the actual Authentication instance
+        // via conf.getAuthentication() at connect time — without it, the client connects with no
+        // credentials and the broker rejects the handshake. Under PIP-478 the plugin is built eagerly
+        // at authentication(...) and resolved into conf.authentication at build();
+        // resolveAuthenticationForTest() runs that resolution. Use the v4 AuthenticationDisabled stub
+        // which always exists on the classpath; we only care that *some* instance lands on the conf.
         PulsarClientBuilderV5 builder = new PulsarClientBuilderV5();
         builder.authentication(
... 15414 lines suppressed ...