Repository: grpc/grpc-go

Issue:
Change in `GRPC_XDS_BOOTSTRAP` behaviour between `grpc-go` versions
I am not sure if this is considered a bug / breaking change or by design

Previous versions of `grpc-go` checked the existence of `XDSBootstrapFileNameEnv = "GRPC_XDS_BOOTSTRAP"` at `init()` but the latest version is creating the xds client pool at `init`. Which means that previously we could pass an env variable that points to a file and have the program itself construct the config.

The snippet below shows the current behaviour
```
func init() {
	internal.TriggerXDSResourceNotFoundForTesting = triggerXDSResourceNotFoundForTesting
	xdsclientinternal.ResourceWatchStateForTesting = resourceWatchStateForTesting

	// DefaultPool is initialized with bootstrap configuration from one of the
	// supported environment variables. If the environment variables are not
	// set, then fallback bootstrap configuration should be set before
	// attempting to create an xDS client, else xDS client creation will fail.
	config, err := bootstrap.GetConfiguration()
	if err != nil {
		if logger.V(2) {
			logger.Infof("Failed to read xDS bootstrap config from env vars:  %v", err)
		}
		DefaultPool = &Pool{clients: make(map[string]*clientRefCounted)}
		return
	}
	DefaultPool = &Pool{clients: make(map[string]*clientRefCounted), config: config}
}
```

At `init()` we call `bootstrap.GetConfiguration()` and then construct a pool that is invalid because `config` is a `nil`.

This is a breaking change because the program here https://gist.github.com/davinci26/4d4a9dcd1e5e7d7842037a438132e813 worked with `v.1.70` but does not work with `v.1.71`


On why we construct the `json` file in-process is mostly as a convenience factor so we can add the `locality` field based on the process information.


Discussion:
@davinci26: I suspect the commit that caused the difference is this one https://github.com/grpc/grpc-go/commit/79b6830e4bbe341cbb855c86b1caa5482d9d040a#diff-8c0a0efa1bb340ffe5846ebebb64f9182dcbb1218d0a51a6b3d6d340072ab07e@purnesh42H: @davinci26 yeah the library now expect the bootstrap file to be present at the init time after the changes in https://github.com/grpc/grpc-go/pull/7898. It is because internally we have a way to set fallback bootstrap config but only if the env vars were not set https://github.com/grpc/grpc-go/blob/master/xds/internal/xdsclient/pool.go#L148 but its not exposed. It was done mainly because we wanted to disallow overriding. Our example recommend the bootstrap file to be present beforehand https://github.com/grpc/grpc-go/blob/master/examples/features/xds/README.md#the-client but since the previous versions allowed creating and setting the bootstrap file at runtime, I will ask other maintainers if we want to expose setting fallback config for backward compatibility.@davinci26: @purnesh42H totally fair, we are maybe in a [hyrum law](https://www.hyrumslaw.com) situation where you are a victim of your own success. This is a breaking change for us so we would happily take the escape hatch for back compatibility. We are excited to try out other features like `fallback` and the new `metrics` you added so we are super keen to upgrade to `v1.71.0` but this makes it a bit tricky.

So we can plan a bit internally:
* When would you know if the maintainers would be ok with exposing a setting back-compat config option?
* **if** this option where to be exposed would it be backported to `v1.71.0` as a `bug-fix` or would it be available in `v1.72.0`?@easwars: Reading the bootstrap configuration is supposed to happen *once* during program init and the fact that whatever you are doing was working earlier is exactly a Hyrum's law situation. We are sorry for breaking you, but we don't have any plans of exposing the API to set fallback bootstrap configuration (that gets used when the environment variables are not set). In fact, all the APIs used by the xDS client are internal only APIs, and exposing anything from there would lock us in since we are planning to make some major changes there.

Maybe, if we understand your deployment better, we might be able to suggest ways that you can use to get things to work with the existing functionality/behavior. 

Do you run in a containerized environment? If so, we do have a binary which can be run as an init container to generate bootstrap configuration and place it in a well known location for your program to pick up. Please see: https://github.com/GoogleCloudPlatform/traffic-director-grpc-bootstrap@davinci26: > Do you run in a containerized environment? If so, we do have a binary which can be run as an init container to generate bootstrap configuration and place it in a well known location for your program to pick up.

This is the alternative we are considering along with other `k8s` specific alternative. We have ways out to solve the problem but since this is a breaking change for us, we need to plan/refactor and change existing applications before we upgrade so we are going to have to take engineering/development cost before we can upgrade our `grpc` version. 

> Maybe, if we understand your deployment better, we might be able to suggest ways that you can use to get things to work with the existing functionality/behavior.

100% up to you as the maintainer team. I think the perspective is that having the ability for the program to configure the library is a nice feature and makes the UX much better. Because while the problem can be addressed with an `init-container` (or other infrastructure solutions) requiring special infrastructure to configure a facet of your application increases friction.

Especially since `bootstrap.json` contains fields like `locality` and `metadata` which are not known at `build` time

So I guess the verdict is that we should solve this at the infrastructure layer? (I am ok with either outcome just wanted the clarity so we can plan internally next steps)@easwars: I checked with other maintainers and looks like other gRPC languages don't read the bootstrap file pointed to by the environment variable at init time. Instead they do it lazily when the first xDS client is created (which is triggered either when the first xDS enabled channel or server is created). So, we will make the same change in grpc-go as well.

Thanks for filing the issue and engaging in a useful conversation.@davinci26: > So, we will make the same change in grpc-go as well.

That is great to hear and thanks a lot of hearing our feedback/viewpoint! A follow up question, would this fix be backported to the latest release [1.71.0](https://github.com/grpc/grpc-go/releases/tag/v1.71.0)? Or would it be available only in `v.1.72.0`?@purnesh42H: > would this fix be backported to the latest release [1.71.0](https://github.com/grpc/grpc-go/releases/tag/v1.71.0)? Or would it be available only in v.1.72.0?

We will do a patch release 1.71.1@davinci26: @purnesh42H / @easwars is there an expected timeline on when `1.71.1` is expected to come out?

Solution patch:
diff --git a/credentials/tls/certprovider/pemfile/watcher.go b/credentials/tls/certprovider/pemfile/watcher.go
index 7ed5c53b..d7b5e1f1 100644
--- a/credentials/tls/certprovider/pemfile/watcher.go
+++ b/credentials/tls/certprovider/pemfile/watcher.go
@@ -38,6 +38,7 @@ import (
 
 	"google.golang.org/grpc/credentials/tls/certprovider"
 	"google.golang.org/grpc/grpclog"
+	"google.golang.org/grpc/internal/credentials/spiffe"
 )
 
 const defaultCertRefreshDuration = 1 * time.Hour
@@ -61,6 +62,11 @@ type Options struct {
 	// RootFile is the file that holds trusted root certificate(s).
 	// Optional.
 	RootFile string
+	// SPIFFEBundleMapFile is the file that holds the spiffe bundle map.
+	// If a given provider configures both the RootFile and the
+	// SPIFFEBundleMapFile, the SPIFFEBundleMapFile will be preferred.
+	// Optional.
+	SPIFFEBundleMapFile string
 	// RefreshDuration is the amount of time the plugin waits before checking
 	// for updates in the specified files.
 	// Optional. If not set, a default value (1 hour) will be used.
@@ -68,11 +74,11 @@ type Options struct {
 }
 
 func (o Options) canonical() []byte {
-	return []byte(fmt.Sprintf("%s:%s:%s:%s", o.CertFile, o.KeyFile, o.RootFile, o.RefreshDuration))
+	return []byte(fmt.Sprintf("%s:%s:%s:%s:%s", o.CertFile, o.KeyFile, o.RootFile, o.SPIFFEBundleMapFile, o.RefreshDuration))
 }
 
 func (o Options) validate() error {
-	if o.CertFile == "" && o.KeyFile == "" && o.RootFile == "" {
+	if o.CertFile == "" && o.KeyFile == "" && o.RootFile == "" && o.SPIFFEBundleMapFile == "" {
 		return fmt.Errorf("pemfile: at least one credential file needs to be specified")
 	}
 	if keySpecified, certSpecified := o.KeyFile != "", o.CertFile != ""; keySpecified != certSpecified {
@@ -124,13 +130,14 @@ func newProvider(o Options) certprovider.Provider {
 // files and provides the most up-to-date key material for consumption by
 // credentials implementation.
 type watcher struct {
-	identityDistributor distributor
-	rootDistributor     distributor
-	opts                Options
-	certFileContents    []byte
-	keyFileContents     []byte
-	rootFileContents    []byte
-	cancel              context.CancelFunc
+	identityDistributor         distributor
+	rootDistributor             distributor
+	opts                        Options
+	certFileContents            []byte
+	keyFileContents             []byte
+	rootFileContents            []byte
+	spiffeBundleMapFileContents []byte
+	cancel                      context.CancelFunc
 }
 
 // distributor wraps the methods on certprovider.Distributor which are used by
@@ -191,6 +198,35 @@ func (w *watcher) updateRootDistributor() {
 		return
 	}
 
+	// If SPIFFEBundleMap is set, use it and DON'T use the RootFile, even if it
+	// fails
+	if w.opts.SPIFFEBundleMapFile != "" {
+		w.maybeUpdateSPIFFEBundleMap()
+	} else {
+		w.maybeUpdateRootFile()
+	}
+}
+
+func (w *watcher) maybeUpdateSPIFFEBundleMap() {
+	spiffeBundleMapContents, err := os.ReadFile(w.opts.SPIFFEBundleMapFile)
+	if err != nil {
+		logger.Warningf("spiffeBundleMapFile (%s) read failed: %v", w.opts.SPIFFEBundleMapFile, err)
+		return
+	}
+	// If the file contents have not changed, skip updating the distributor.
+	if bytes.Equal(w.spiffeBundleMapFileContents, spiffeBundleMapContents) {
+		return
+	}
+	bundleMap, err := spiffe.BundleMapFromBytes(spiffeBundleMapContents)
+	if err != nil {
+		logger.Warning("Failed to parse spiffe bundle map")
+		return
+	}
+	w.spiffeBundleMapFileContents = spiffeBundleMapContents
+	w.rootDistributor.Set(&certprovider.KeyMaterial{SPIFFEBundleMap: bundleMap}, nil)
+}
+
+func (w *watcher) maybeUpdateRootFile() {
 	rootFileContents, err := os.ReadFile(w.opts.RootFile)
 	if err != nil {
 		logger.Warningf("rootFile (%s) read failed: %v", w.opts.RootFile, err)
@@ -198,7 +234,7 @@ func (w *watcher) updateRootDistributor() {
 	}
 	trustPool := x509.NewCertPool()
 	if !trustPool.AppendCertsFromPEM(rootFileContents) {
-		logger.Warning("failed to parse root certificate")
+		logger.Warning("Failed to parse root certificate")
 		return
 	}
 	// If the file contents have not changed, skip updating the distributor.
@@ -249,6 +285,7 @@ func (w *watcher) KeyMaterial(ctx context.Context) (*certprovider.KeyMaterial, e
 		if err != nil {
 			return nil, err
 		}
+		km.SPIFFEBundleMap = rootKM.SPIFFEBundleMap
 		km.Roots = rootKM.Roots
 	}
 	return km, nil
diff --git a/credentials/tls/certprovider/provider.go b/credentials/tls/certprovider/provider.go
index 07ba05b1..c415b824 100644
--- a/credentials/tls/certprovider/provider.go
+++ b/credentials/tls/certprovider/provider.go
@@ -30,6 +30,7 @@ import (
 	"crypto/x509"
 	"errors"
 
+	"github.com/spiffe/go-spiffe/v2/bundle/spiffebundle"
 	"google.golang.org/grpc/internal"
 )
 
@@ -93,7 +94,12 @@ type KeyMaterial struct {
 	// Certs contains a slice of cert/key pairs used to prove local identity.
 	Certs []tls.Certificate
 	// Roots contains the set of trusted roots to validate the peer's identity.
+	// This field will only be used if the `SPIFFEBundleMap` field is unset.
 	Roots *x509.CertPool
+	// SPIFFEBundleMap is an in-memory representation of a spiffe trust bundle
+	// map. If this value exists, it will be used to find the roots for a given
+	// trust domain rather than the Roots in this struct.
+	SPIFFEBundleMap map[string]*spiffebundle.Bundle
 }
 
 // BuildOptions contains parameters passed to a Provider at build time.
diff --git a/examples/go.mod b/examples/go.mod
index 4ebbe2f1..72eac0ca 100644
--- a/examples/go.mod
+++ b/examples/go.mod
@@ -46,6 +46,7 @@ require (
 	github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
 	github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
 	github.com/felixge/httpsnoop v1.0.4 // indirect
+	github.com/go-jose/go-jose/v4 v4.0.4 // indirect
 	github.com/go-logr/logr v1.4.2 // indirect
 	github.com/go-logr/stdr v1.2.2 // indirect
 	github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
@@ -60,6 +61,8 @@ require (
 	github.com/prometheus/client_model v0.6.1 // indirect
 	github.com/prometheus/common v0.62.0 // indirect
 	github.com/prometheus/procfs v0.15.1 // indirect
+	github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
+	github.com/zeebo/errs v1.4.0 // indirect
 	go.opencensus.io v0.24.0 // indirect
 	go.opentelemetry.io/auto/sdk v1.1.0 // indirect
 	go.opentelemetry.io/contrib/detectors/gcp v1.34.0 // indirect
diff --git a/examples/go.sum b/examples/go.sum
index 2ff8ab6a..9b34e988 100644
--- a/examples/go.sum
+++ b/examples/go.sum
@@ -887,6 +887,7 @@ github.com/go-fonts/stix v0.1.0/go.mod h1:w/c1f0ldAUlJmLBvlbkvVXLAD+tAMqobIIQpmn
 github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E=
 github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc=
 github.com/go-latex/latex v0.0.0-20210118124228-b3d85cf34e07/go.mod h1:CO1AlKB2CSIqUrmQPqA0gdRIlnLEY0gK5JGjh37zN5U=
 github.com/go-latex/latex v0.0.0-20210823091927-c0d11ff05a81/go.mod h1:SX0U8uGpxhq9o2S/CELCSUxEWWAuoCUcVCQWv7G2OCk=
@@ -1100,6 +1101,7 @@ github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY52
 github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
 github.com/spf13/afero v1.9.2/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y=
 github.com/spf13/afero v1.10.0/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ=
+github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE=
 github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
@@ -1125,6 +1127,7 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1
 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
+github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
 github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
 github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
 go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
diff --git a/internal/credentials/spiffe/spiffe.go b/internal/credentials/spiffe/spiffe.go
index 6ce75111..e8a9cef8 100644
--- a/internal/credentials/spiffe/spiffe.go
+++ b/internal/credentials/spiffe/spiffe.go
@@ -24,7 +24,6 @@ package spiffe
 import (
 	"encoding/json"
 	"fmt"
-	"os"
 
 	"github.com/spiffe/go-spiffe/v2/bundle/spiffebundle"
 	"github.com/spiffe/go-spiffe/v2/spiffeid"
@@ -34,33 +33,29 @@ type partialParsedSPIFFEBundleMap struct {
 	Bundles map[string]json.RawMessage `json:"trust_domains"`
 }
 
-// LoadSPIFFEBundleMap loads a SPIFFE Bundle Map from a file. See the SPIFFE
-// Bundle Map spec for more detail -
+// BundleMapFromBytes parses bytes into a SPIFFE Bundle Map. See the
+// SPIFFE Bundle Map spec for more detail -
 // https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE_Trust_Domain_and_Bundle.md#4-spiffe-bundle-format
 // If duplicate keys are encountered in the JSON parsing, Go's default unmarshal
 // behavior occurs which causes the last processed entry to be the entry in the
 // parsed map.
-func LoadSPIFFEBundleMap(filePath string) (map[string]*spiffebundle.Bundle, error) {
-	bundleMapRaw, err := os.ReadFile(filePath)
-	if err != nil {
-		return nil, err
-	}
+func BundleMapFromBytes(bundleMapBytes []byte) (map[string]*spiffebundle.Bundle, error) {
 	var result partialParsedSPIFFEBundleMap
-	if err := json.Unmarshal(bundleMapRaw, &result); err != nil {
+	if err := json.Unmarshal(bundleMapBytes, &result); err != nil {
 		return nil, err
 	}
 	if result.Bundles == nil {
-		return nil, fmt.Errorf("spiffe: no content in spiffe bundle map file %v", filePath)
+		return nil, fmt.Errorf("spiffe: BundleMapFromBytes() no bundles parsed from spiffe bundle map bytes")
 	}
 	bundleMap := map[string]*spiffebundle.Bundle{}
 	for td, jsonBundle := range result.Bundles {
 		trustDomain, err := spiffeid.TrustDomainFromString(td)
 		if err != nil {
-			return nil, fmt.Errorf("spiffe: invalid trust domain (%v) found when parsing map file (%v): %v", td, filePath, err)
+			return nil, fmt.Errorf("spiffe: BundleMapFromBytes() invalid trust domain %q found when parsing SPIFFE Bundle Map: %v", td, err)
 		}
 		bundle, err := spiffebundle.Parse(trustDomain, jsonBundle)
 		if err != nil {
-			return nil, fmt.Errorf("spiffe: failed to parse bundle in map file (%v) for trust domain %v: %v", filePath, td, err)
+			return nil, fmt.Errorf("spiffe: BundleMapFromBytes() failed to parse bundle for trust domain %q: %v", td, err)
 		}
 		bundleMap[td] = bundle
 	}
diff --git a/interop/xds/go.mod b/interop/xds/go.mod
index 3e92c896..a635894e 100644
--- a/interop/xds/go.mod
+++ b/interop/xds/go.mod
@@ -20,6 +20,7 @@ require (
 	github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 // indirect
 	github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
 	github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
+	github.com/go-jose/go-jose/v4 v4.0.4 // indirect
 	github.com/go-logr/logr v1.4.2 // indirect
 	github.com/go-logr/stdr v1.2.2 // indirect
 	github.com/google/uuid v1.6.0 // indirect
@@ -29,12 +30,15 @@ require (
 	github.com/prometheus/client_model v0.6.1 // indirect
 	github.com/prometheus/common v0.62.0 // indirect
 	github.com/prometheus/procfs v0.15.1 // indirect
+	github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
+	github.com/zeebo/errs v1.4.0 // indirect
 	go.opentelemetry.io/auto/sdk v1.1.0 // indirect
 	go.opentelemetry.io/contrib/detectors/gcp v1.34.0 // indirect
 	go.opentelemetry.io/otel v1.34.0 // indirect
 	go.opentelemetry.io/otel/metric v1.34.0 // indirect
 	go.opentelemetry.io/otel/sdk v1.34.0 // indirect
 	go.opentelemetry.io/otel/trace v1.34.0 // indirect
+	golang.org/x/crypto v0.33.0 // indirect
 	golang.org/x/net v0.35.0 // indirect
 	golang.org/x/oauth2 v0.26.0 // indirect
 	golang.org/x/sync v0.11.0 // indirect
diff --git a/interop/xds/go.sum b/interop/xds/go.sum
index 48207084..def9bed7 100644
--- a/interop/xds/go.sum
+++ b/interop/xds/go.sum
@@ -20,6 +20,8 @@ github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an
 github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
 github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
 github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
+github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E=
+github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc=
 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
 github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
 github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -49,8 +51,12 @@ github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ
 github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
 github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
 github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
+github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE=
+github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g=
 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
+github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
 go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
 go.opentelemetry.io/contrib/detectors/gcp v1.34.0 h1:JRxssobiPg23otYU5SbWtQC//snGVIM3Tx6QRzlQBao=
@@ -67,6 +73,8 @@ go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce
 go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
 go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
 go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
+golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
+golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
 golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
 golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
 golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE=
diff --git a/security/advancedtls/examples/go.mod b/security/advancedtls/examples/go.mod
index 7b99232e..6ebd7629 100644
--- a/security/advancedtls/examples/go.mod
+++ b/security/advancedtls/examples/go.mod
@@ -9,6 +9,9 @@ require (
 )
 
 require (
+	github.com/go-jose/go-jose/v4 v4.0.4 // indirect
+	github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
+	github.com/zeebo/errs v1.4.0 // indirect
 	golang.org/x/crypto v0.33.0 // indirect
 	golang.org/x/net v0.35.0 // indirect
 	golang.org/x/sys v0.30.0 // indirect
diff --git a/security/advancedtls/examples/go.sum b/security/advancedtls/examples/go.sum
index f8d73db3..f4e308b8 100644
--- a/security/advancedtls/examples/go.sum
+++ b/security/advancedtls/examples/go.sum
@@ -1,3 +1,7 @@
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E=
+github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc=
 github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
 github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@@ -8,6 +12,14 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE=
+github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
+github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
 go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
 go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
@@ -32,3 +44,5 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:
 google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ=
 google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
 google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/security/advancedtls/go.mod b/security/advancedtls/go.mod
index 1c4db623..7114bd95 100644
--- a/security/advancedtls/go.mod
+++ b/security/advancedtls/go.mod
@@ -10,6 +10,9 @@ require (
 )
 
 require (
+	github.com/go-jose/go-jose/v4 v4.0.4 // indirect
+	github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
+	github.com/zeebo/errs v1.4.0 // indirect
 	golang.org/x/net v0.35.0 // indirect
 	golang.org/x/sys v0.30.0 // indirect
 	golang.org/x/text v0.22.0 // indirect
diff --git a/security/advancedtls/go.sum b/security/advancedtls/go.sum
index f8d73db3..f4e308b8 100644
--- a/security/advancedtls/go.sum
+++ b/security/advancedtls/go.sum
@@ -1,3 +1,7 @@
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E=
+github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc=
 github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
 github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@@ -8,6 +12,14 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE=
+github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
+github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
 go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
 go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
@@ -32,3 +44,5 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:
 google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ=
 google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
 google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/xds/internal/xdsclient/clientimpl.go b/xds/internal/xdsclient/clientimpl.go
index c30c2b45..966986d2 100644
--- a/xds/internal/xdsclient/clientimpl.go
+++ b/xds/internal/xdsclient/clientimpl.go
@@ -113,19 +113,7 @@ func init() {
 	internal.TriggerXDSResourceNotFoundForTesting = triggerXDSResourceNotFoundForTesting
 	xdsclientinternal.ResourceWatchStateForTesting = resourceWatchStateForTesting
 
-	// DefaultPool is initialized with bootstrap configuration from one of the
-	// supported environment variables. If the environment variables are not
-	// set, then fallback bootstrap configuration should be set before
-	// attempting to create an xDS client, else xDS client creation will fail.
-	config, err := bootstrap.GetConfiguration()
-	if err != nil {
-		if logger.V(2) {
-			logger.Infof("Failed to read xDS bootstrap config from env vars:  %v", err)
-		}
-		DefaultPool = &Pool{clients: make(map[string]*clientRefCounted)}
-		return
-	}
-	DefaultPool = &Pool{clients: make(map[string]*clientRefCounted), config: config}
+	DefaultPool = &Pool{clients: make(map[string]*clientRefCounted)}
 }
 
 // newClientImpl returns a new xdsClient with the given config.
diff --git a/xds/internal/xdsclient/pool.go b/xds/internal/xdsclient/pool.go
index 4f3ad7c4..4a9c0e09 100644
--- a/xds/internal/xdsclient/pool.go
+++ b/xds/internal/xdsclient/pool.go
@@ -220,7 +220,25 @@ func (p *Pool) newRefCounted(name string, watchExpiryTimeout time.Duration, stre
 	defer p.mu.Unlock()
 
 	if p.config == nil {
-		return nil, nil, fmt.Errorf("xds: bootstrap configuration not set in the pool")
+		if len(p.clients) != 0 || p != DefaultPool {
+			// If the current pool `p` already contains xDS clients or it is not
+			// the `DefaultPool`, the bootstrap config should have been already
+			// present in the pool.
+			return nil, nil, fmt.Errorf("xds: bootstrap configuration not set in the pool")
+		}
+		// If the current pool `p` is the `DefaultPool` and has no clients, it
+		// might be the first time an xDS client is being created on it. So,
+		// the bootstrap configuration is read from environment variables.
+		//
+		// DefaultPool is initialized with bootstrap configuration from one of the
+		// supported environment variables. If the environment variables are not
+		// set, then fallback bootstrap configuration should be set before
+		// attempting to create an xDS client, else xDS client creation will fail.
+		config, err := bootstrap.GetConfiguration()
+		if err != nil {
+			return nil, nil, fmt.Errorf("xds: failed to read xDS bootstrap config from env vars:  %v", err)
+		}
+		p.config = config
 	}
 
 	if c := p.clients[name]; c != nil {



Test patch:
diff --git a/credentials/tls/certprovider/pemfile/builder_test.go b/credentials/tls/certprovider/pemfile/builder_test.go
index dfd5ee89..1a556d11 100644
--- a/credentials/tls/certprovider/pemfile/builder_test.go
+++ b/credentials/tls/certprovider/pemfile/builder_test.go
@@ -94,7 +94,7 @@ func TestParseConfig(t *testing.T) {
 				"private_key_file":    "/a/b/key.pem",
 				"ca_certificate_file": "/a/b/ca.pem"
 			}`),
-			wantOutput: "file_watcher:/a/b/cert.pem:/a/b/key.pem:/a/b/ca.pem:10m0s",
+			wantOutput: "file_watcher:/a/b/cert.pem:/a/b/key.pem:/a/b/ca.pem::10m0s",
 		},
 		{
 			desc: "good config",
@@ -105,7 +105,7 @@ func TestParseConfig(t *testing.T) {
 				"ca_certificate_file": "/a/b/ca.pem",
 				"refresh_interval":   "200s"
 			}`),
-			wantOutput: "file_watcher:/a/b/cert.pem:/a/b/key.pem:/a/b/ca.pem:3m20s",
+			wantOutput: "file_watcher:/a/b/cert.pem:/a/b/key.pem:/a/b/ca.pem::3m20s",
 		},
 	}
 
diff --git a/credentials/tls/certprovider/pemfile/watcher_test.go b/credentials/tls/certprovider/pemfile/watcher_test.go
index 3f015833..08e67ad5 100644
--- a/credentials/tls/certprovider/pemfile/watcher_test.go
+++ b/credentials/tls/certprovider/pemfile/watcher_test.go
@@ -26,6 +26,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/google/go-cmp/cmp"
 	"google.golang.org/grpc/credentials/tls/certprovider"
 	"google.golang.org/grpc/internal/grpctest"
 	"google.golang.org/grpc/internal/testutils"
@@ -35,9 +36,10 @@ import (
 const (
 	// These are the names of files inside temporary directories, which the
 	// plugin is asked to watch.
-	certFile = "cert.pem"
-	keyFile  = "key.pem"
-	rootFile = "ca.pem"
+	certFile         = "cert.pem"
+	keyFile          = "key.pem"
+	rootFile         = "ca.pem"
+	spiffeBundleFile = "spiffebundle.json"
 
 	defaultTestRefreshDuration = 100 * time.Millisecond
 	defaultTestTimeout         = 5 * time.Second
@@ -65,6 +67,10 @@ func compareKeyMaterial(got, want *certprovider.KeyMaterial) error {
 		return fmt.Errorf("keyMaterial roots = %v, want %v", gotR, wantR)
 	}
 
+	if gotBundle, wantBundle := got.SPIFFEBundleMap, want.SPIFFEBundleMap; !cmp.Equal(gotBundle, wantBundle) {
+		return fmt.Errorf("keyMaterial spiffe bundle map = %v, want %v", gotBundle, wantBundle)
+	}
+
 	return nil
 }
 
@@ -107,12 +113,19 @@ func (s) TestNewProvider(t *testing.T) {
 				RootFile: testdata.Path("x509/client_ca_cert.pem"),
 			},
 		},
+		{
+			desc: "Only spiffe bundle map specified",
+			options: Options{
+				SPIFFEBundleMapFile: testdata.Path("spiffe/spiffebundle.json"),
+			},
+		},
 		{
 			desc: "Everything is specified",
 			options: Options{
-				KeyFile:  testdata.Path("x509/client1_key.pem"),
-				CertFile: testdata.Path("x509/client1_cert.pem"),
-				RootFile: testdata.Path("x509/client_ca_cert.pem"),
+				KeyFile:             testdata.Path("x509/client1_key.pem"),
+				CertFile:            testdata.Path("x509/client1_cert.pem"),
+				RootFile:            testdata.Path("x509/client_ca_cert.pem"),
+				SPIFFEBundleMapFile: testdata.Path("spiffe/spiffebundle.json"),
 			},
 			wantError: false,
 		},
@@ -164,11 +177,19 @@ func createTmpFile(t *testing.T, src, dst string) {
 	t.Logf("%s", string(data))
 }
 
+func removeTmpFile(t *testing.T, filePath string) {
+	t.Helper()
+	if err := os.Remove(filePath); err != nil {
+		t.Fatalf("os.RemoveFIle(%q) failed: %v", filePath, err)
+	}
+	t.Logf("Removed file at: %s", filePath)
+}
+
 // createTempDirWithFiles creates a temporary directory under the system default
 // tempDir with the given dirSuffix. It also reads from certSrc, keySrc and
 // rootSrc files are creates appropriate files under the newly create tempDir.
 // Returns the name of the created tempDir.
-func createTmpDirWithFiles(t *testing.T, dirSuffix, certSrc, keySrc, rootSrc string) string {
+func createTmpDirWithFiles(t *testing.T, dirSuffix, certSrc, keySrc, rootSrc, spiffeBundleSrc string) string {
 	t.Helper()
 
 	// Create a temp directory. Passing an empty string for the first argument
@@ -182,12 +203,13 @@ func createTmpDirWithFiles(t *testing.T, dirSuffix, certSrc, keySrc, rootSrc str
 	createTmpFile(t, testdata.Path(certSrc), path.Join(dir, certFile))
 	createTmpFile(t, testdata.Path(keySrc), path.Join(dir, keyFile))
 	createTmpFile(t, testdata.Path(rootSrc), path.Join(dir, rootFile))
+	createTmpFile(t, testdata.Path(spiffeBundleSrc), path.Join(dir, spiffeBundleFile))
 	return dir
 }
 
 // initializeProvider performs setup steps common to all tests (except the one
 // which uses symlinks).
-func initializeProvider(t *testing.T, testName string) (string, certprovider.Provider, *testutils.Channel, func()) {
+func initializeProvider(t *testing.T, testName string, useSPIFFEBundle bool) (string, certprovider.Provider, *testutils.Channel, func()) {
 	t.Helper()
 
 	// Override the newDistributor to one which pushes on a channel that we
@@ -198,13 +220,16 @@ func initializeProvider(t *testing.T, testName string) (string, certprovider.Pro
 	newDistributor = func() distributor { return d }
 
 	// Create a new provider to watch the files in tmpdir.
-	dir := createTmpDirWithFiles(t, testName+"*", "x509/client1_cert.pem", "x509/client1_key.pem", "x509/client_ca_cert.pem")
+	dir := createTmpDirWithFiles(t, testName+"*", "x509/client1_cert.pem", "x509/client1_key.pem", "x509/client_ca_cert.pem", "spiffe/spiffebundle.json")
 	opts := Options{
 		CertFile:        path.Join(dir, certFile),
 		KeyFile:         path.Join(dir, keyFile),
 		RootFile:        path.Join(dir, rootFile),
 		RefreshDuration: defaultTestRefreshDuration,
 	}
+	if useSPIFFEBundle {
+		opts.SPIFFEBundleMapFile = path.Join(dir, spiffeBundleFile)
+	}
 	prov, err := NewProvider(opts)
 	if err != nil {
 		t.Fatalf("NewProvider(%+v) failed: %v", opts, err)
@@ -218,7 +243,7 @@ func initializeProvider(t *testing.T, testName string) (string, certprovider.Pro
 		// Since we have root and identity certs, we need to make sure the
 		// update is pushed on both of them.
 		if _, err := distCh.Receive(ctx); err != nil {
-			t.Fatalf("timeout waiting for provider to read files and push key material to distributor: %v", err)
+			t.Fatalf("Timeout waiting for provider to read files and push key material to distributor: %v", err)
 		}
 	}
 
@@ -232,21 +257,30 @@ func initializeProvider(t *testing.T, testName string) (string, certprovider.Pro
 // successfully, and the underlying files do not change. Verifies that the
 // plugin does not push new updates to the distributor in this case.
 func (s) TestProvider_NoUpdate(t *testing.T) {
-	_, prov, distCh, cancel := initializeProvider(t, "no_update")
-	defer cancel()
-
-	// Make sure the provider is healthy and returns key material.
-	ctx, cc := context.WithTimeout(context.Background(), defaultTestTimeout)
-	defer cc()
-	if _, err := prov.KeyMaterial(ctx); err != nil {
-		t.Fatalf("provider.KeyMaterial() failed: %v", err)
-	}
+	baseName := "no_update"
+	for _, useSPIFFEBundle := range []bool{true, false} {
+		testName := baseName
+		if useSPIFFEBundle {
+			testName = testName + "_" + "withSPIFFEBundle"
+		}
+		t.Run(testName, func(t *testing.T) {
+			_, prov, distCh, cancel := initializeProvider(t, "no_update", useSPIFFEBundle)
+			defer cancel()
+
+			// Make sure the provider is healthy and returns key material.
+			ctx, cc := context.WithTimeout(context.Background(), defaultTestTimeout)
+			defer cc()
+			if _, err := prov.KeyMaterial(ctx); err != nil {
+				t.Fatalf("provider.KeyMaterial() failed: %v", err)
+			}
 
-	// Files haven't change. Make sure no updates are pushed by the provider.
-	sCtx, sc := context.WithTimeout(context.Background(), 2*defaultTestRefreshDuration)
-	defer sc()
-	if _, err := distCh.Receive(sCtx); err == nil {
-		t.Fatal("new key material pushed to distributor when underlying files did not change")
+			// Files haven't change. Make sure no updates are pushed by the provider.
+			sCtx, sc := context.WithTimeout(context.Background(), 2*defaultTestRefreshDuration)
+			defer sc()
+			if _, err := distCh.Receive(sCtx); err == nil {
+				t.Fatal("New key material pushed to distributor when underlying files did not change")
+			}
+		})
 	}
 }
 
@@ -254,7 +288,165 @@ func (s) TestProvider_NoUpdate(t *testing.T) {
 // created successfully and the underlying files change. Verifies that the
 // changes are picked up by the provider.
 func (s) TestProvider_UpdateSuccess(t *testing.T) {
-	dir, prov, distCh, cancel := initializeProvider(t, "update_success")
+	baseName := "update_success"
+	for _, useSPIFFEBundle := range []bool{true, false} {
+		testName := baseName
+		if useSPIFFEBundle {
+			testName = testName + "_" + "withSPIFFEBundle"
+		}
+		t.Run(testName, func(t *testing.T) {
+			dir, prov, distCh, cancel := initializeProvider(t, "update_success", useSPIFFEBundle)
+			defer cancel()
+
+			// Make sure the provider is healthy and returns key material.
+			ctx, cc := context.WithTimeout(context.Background(), defaultTestTimeout)
+			defer cc()
+			km1, err := prov.KeyMaterial(ctx)
+			if err != nil {
+				t.Fatalf("provider.KeyMaterial() failed: %v", err)
+			}
+
+			// Change only the root file.
+			if useSPIFFEBundle {
+				createTmpFile(t, testdata.Path("spiffe/spiffebundle2.json"), path.Join(dir, spiffeBundleFile))
+			} else {
+				createTmpFile(t, testdata.Path("x509/server_ca_cert.pem"), path.Join(dir, rootFile))
+			}
+			if _, err := distCh.Receive(ctx); err != nil {
+				t.Fatal("Timeout waiting for new key material to be pushed to the distributor")
+			}
+
+			// Make sure update is picked up.
+			km2, err := prov.KeyMaterial(ctx)
+			if err != nil {
+				t.Fatalf("provider.KeyMaterial() failed: %v", err)
+			}
+			if err := compareKeyMaterial(km1, km2); err == nil {
+				t.Fatal("Expected provider to return new key material after update to underlying file")
+			}
+
+			// Change only cert/key files.
+			createTmpFile(t, testdata.Path("x509/client2_cert.pem"), path.Join(dir, certFile))
+			createTmpFile(t, testdata.Path("x509/client2_key.pem"), path.Join(dir, keyFile))
+			if _, err := distCh.Receive(ctx); err != nil {
+				t.Fatal("Timeout waiting for new key material to be pushed to the distributor")
+			}
+
+			// Make sure update is picked up.
+			km3, err := prov.KeyMaterial(ctx)
+			if err != nil {
+				t.Fatalf("provider.KeyMaterial() failed: %v", err)
+			}
+			if err := compareKeyMaterial(km2, km3); err == nil {
+				t.Fatal("Expected provider to return new key material after update to underlying file")
+			}
+		})
+	}
+}
+
+// TestProvider_UpdateSuccessWithSymlink tests the case where a file watcher
+// plugin is created successfully to watch files through a symlink and the
+// symlink is updates to point to new files. Verifies that the changes are
+// picked up by the provider.
+func (s) TestProvider_UpdateSuccessWithSymlink(t *testing.T) {
+	baseName := "update_with_symlink"
+	for _, useSPIFFEBundle := range []bool{true, false} {
+		testName := baseName
+		if useSPIFFEBundle {
+			testName = testName + "_" + "withSPIFFEBundle"
+		}
+		t.Run(testName, func(t *testing.T) {
+			// Override the newDistributor to one which pushes on a channel that we
+			// can block on.
+			origDistributorFunc := newDistributor
+			distCh := testutils.NewChannel()
+			d := newWrappedDistributor(distCh)
+			newDistributor = func() distributor { return d }
+			defer func() { newDistributor = origDistributorFunc }()
+
+			// Create two tempDirs with different files.
+			dir1 := createTmpDirWithFiles(t, "update_with_symlink1_*", "x509/client1_cert.pem", "x509/client1_key.pem", "x509/client_ca_cert.pem", "spiffe/spiffebundle.json")
+			dir2 := createTmpDirWithFiles(t, "update_with_symlink2_*", "x509/server1_cert.pem", "x509/server1_key.pem", "x509/server_ca_cert.pem", "spiffe/spiffebundle2.json")
+
+			// Create a symlink under a new tempdir, and make it point to dir1.
+			tmpdir, err := os.MkdirTemp("", "test_symlink_*")
+			if err != nil {
+				t.Fatalf("os.MkdirTemp() failed: %v", err)
+			}
+			symLinkName := path.Join(tmpdir, "test_symlink")
+			if err := os.Symlink(dir1, symLinkName); err != nil {
+				t.Fatalf("Failed to create symlink to %q: %v", dir1, err)
+			}
+
+			// Create a provider which watches the files pointed to by the symlink.
+			opts := Options{
+				CertFile:            path.Join(symLinkName, certFile),
+				KeyFile:             path.Join(symLinkName, keyFile),
+				RootFile:            path.Join(symLinkName, rootFile),
+				SPIFFEBundleMapFile: path.Join(symLinkName, spiffeBundleFile),
+				RefreshDuration:     defaultTestRefreshDuration,
+			}
+			if useSPIFFEBundle {
+				opts.SPIFFEBundleMapFile = path.Join(symLinkName, spiffeBundleFile)
+			}
+			prov, err := NewProvider(opts)
+			if err != nil {
+				t.Fatalf("NewProvider(%+v) failed: %v", opts, err)
+			}
+			defer prov.Close()
+
+			// Make sure the provider picks up the files and pushes the key material on
+			// to the distributors.
+			ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
+			defer cancel()
+			for i := 0; i < 2; i++ {
+				// Since we have root and identity certs, we need to make sure the
+				// update is pushed on both of them.
+				if _, err := distCh.Receive(ctx); err != nil {
+					t.Fatalf("Timeout waiting for provider to read files and push key material to distributor: %v", err)
+				}
+			}
+			km1, err := prov.KeyMaterial(ctx)
+			if err != nil {
+				t.Fatalf("provider.KeyMaterial() failed: %v", err)
+			}
+
+			// Update the symlink to point to dir2.
+			symLinkTmpName := path.Join(tmpdir, "test_symlink.tmp")
+			if err := os.Symlink(dir2, symLinkTmpName); err != nil {
+				t.Fatalf("Failed to create symlink to %q: %v", dir2, err)
+			}
+			if err := os.Rename(symLinkTmpName, symLinkName); err != nil {
+				t.Fatalf("Failed to update symlink: %v", err)
+			}
+
+			// Make sure the provider picks up the new files and pushes the key material
+			// on to the distributors.
+			for i := 0; i < 2; i++ {
+				// Since we have root and identity certs, we need to make sure the
+				// update is pushed on both of them.
+				if _, err := distCh.Receive(ctx); err != nil {
+					t.Fatalf("Timeout waiting for provider to read files and push key material to distributor: %v", err)
+				}
+			}
+			km2, err := prov.KeyMaterial(ctx)
+			if err != nil {
+				t.Fatalf("provider.KeyMaterial() failed: %v", err)
+			}
+
+			if err := compareKeyMaterial(km1, km2); err == nil {
+				t.Fatal("Expected provider to return new key material after symlink update")
+			}
+		})
+	}
+}
+
+// TestProvider_UpdateFailure_ThenSuccess tests the case where updating cert/key
+// files fail. Verifies that the failed update does not push anything on the
+// distributor. Then the update succeeds, and the test verifies that the key
+// material is updated.
+func (s) TestProvider_UpdateFailure_ThenSuccess(t *testing.T) {
+	dir, prov, distCh, cancel := initializeProvider(t, "update_failure", false)
 	defer cancel()
 
 	// Make sure the provider is healthy and returns key material.
@@ -265,119 +457,137 @@ func (s) TestProvider_UpdateSuccess(t *testing.T) {
 		t.Fatalf("provider.KeyMaterial() failed: %v", err)
 	}
 
-	// Change only the root file.
-	createTmpFile(t, testdata.Path("x509/server_ca_cert.pem"), path.Join(dir, rootFile))
-	if _, err := distCh.Receive(ctx); err != nil {
-		t.Fatal("timeout waiting for new key material to be pushed to the distributor")
+	// Update only the cert file. The key file is left unchanged. This should
+	// lead to these two files being not compatible with each other. This
+	// simulates the case where the watching goroutine might catch the files in
+	// the midst of an update.
+	createTmpFile(t, testdata.Path("x509/server1_cert.pem"), path.Join(dir, certFile))
+
+	// Since the last update left the files in an incompatible state, the update
+	// should not be picked up by our provider.
+	sCtx, sc := context.WithTimeout(context.Background(), 2*defaultTestRefreshDuration)
+	defer sc()
+	if _, err := distCh.Receive(sCtx); err == nil {
+		t.Fatal("New key material pushed to distributor when underlying files did not change")
 	}
 
-	// Make sure update is picked up.
+	// The provider should return key material corresponding to the old state.
 	km2, err := prov.KeyMaterial(ctx)
 	if err != nil {
 		t.Fatalf("provider.KeyMaterial() failed: %v", err)
 	}
-	if err := compareKeyMaterial(km1, km2); err == nil {
-		t.Fatal("expected provider to return new key material after update to underlying file")
+	if err := compareKeyMaterial(km1, km2); err != nil {
+		t.Fatalf("Expected provider to not update key material: %v", err)
 	}
 
-	// Change only cert/key files.
-	createTmpFile(t, testdata.Path("x509/client2_cert.pem"), path.Join(dir, certFile))
-	createTmpFile(t, testdata.Path("x509/client2_key.pem"), path.Join(dir, keyFile))
-	if _, err := distCh.Receive(ctx); err != nil {
-		t.Fatal("timeout waiting for new key material to be pushed to the distributor")
-	}
+	// Update the key file to match the cert file.
+	createTmpFile(t, testdata.Path("x509/server1_key.pem"), path.Join(dir, keyFile))
 
 	// Make sure update is picked up.
+	if _, err := distCh.Receive(ctx); err != nil {
+		t.Fatal("Timeout waiting for new key material to be pushed to the distributor")
+	}
 	km3, err := prov.KeyMaterial(ctx)
 	if err != nil {
 		t.Fatalf("provider.KeyMaterial() failed: %v", err)
 	}
 	if err := compareKeyMaterial(km2, km3); err == nil {
-		t.Fatal("expected provider to return new key material after update to underlying file")
+		t.Fatal("Expected provider to return new key material after update to underlying file")
 	}
 }
 
-// TestProvider_UpdateSuccessWithSymlink tests the case where a file watcher
-// plugin is created successfully to watch files through a symlink and the
-// symlink is updates to point to new files. Verifies that the changes are
-// picked up by the provider.
-func (s) TestProvider_UpdateSuccessWithSymlink(t *testing.T) {
-	// Override the newDistributor to one which pushes on a channel that we
-	// can block on.
-	origDistributorFunc := newDistributor
-	distCh := testutils.NewChannel()
-	d := newWrappedDistributor(distCh)
-	newDistributor = func() distributor { return d }
-	defer func() { newDistributor = origDistributorFunc }()
+// TestProvider_UpdateFailure_ThenSuccess tests the case where updating cert/key
+// files fail. Verifies that the failed update does not push anything on the
+// distributor. Then the update succeeds, and the test verifies that the key
+// material is updated.
+func (s) TestProvider_UpdateFailureSPIFFE(t *testing.T) {
+	tests := []struct {
+		name    string
+		badFile string
+	}{
+		{
+			name:    "malformed spiffe",
+			badFile: "spiffe/spiffebundle_malformed.json",
+		},
+		{
+			name:    "invalid bundle",
+			badFile: "spiffe/spiffebundle_wrong_kty.json",
+		},
+		{
+			name:    "cert in the x5c field is invalid",
+			badFile: "spiffe/spiffebundle_corrupted_cert.json",
+		},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			dir, prov, distCh, cancel := initializeProvider(t, tc.name, true)
+			defer cancel()
 
-	// Create two tempDirs with different files.
-	dir1 := createTmpDirWithFiles(t, "update_with_symlink1_*", "x509/client1_cert.pem", "x509/client1_key.pem", "x509/client_ca_cert.pem")
-	dir2 := createTmpDirWithFiles(t, "update_with_symlink2_*", "x509/server1_cert.pem", "x509/server1_key.pem", "x509/server_ca_cert.pem")
+			// Make sure the provider is healthy and returns key material.
+			ctx, cc := context.WithTimeout(context.Background(), defaultTestTimeout)
+			defer cc()
+			km1, err := prov.KeyMaterial(ctx)
+			if err != nil {
+				t.Fatalf("provider.KeyMaterial() failed: %v", err)
+			}
 
-	// Create a symlink under a new tempdir, and make it point to dir1.
-	tmpdir, err := os.MkdirTemp("", "test_symlink_*")
-	if err != nil {
-		t.Fatalf("os.MkdirTemp() failed: %v", err)
-	}
-	symLinkName := path.Join(tmpdir, "test_symlink")
-	if err := os.Symlink(dir1, symLinkName); err != nil {
-		t.Fatalf("failed to create symlink to %q: %v", dir1, err)
-	}
+			// Update the file with a bad update
+			createTmpFile(t, testdata.Path(tc.badFile), path.Join(dir, spiffeBundleFile))
 
-	// Create a provider which watches the files pointed to by the symlink.
-	opts := Options{
-		CertFile:        path.Join(symLinkName, certFile),
-		KeyFile:         path.Join(symLinkName, keyFile),
-		RootFile:        path.Join(symLinkName, rootFile),
-		RefreshDuration: defaultTestRefreshDuration,
-	}
-	prov, err := NewProvider(opts)
-	if err != nil {
-		t.Fatalf("NewProvider(%+v) failed: %v", opts, err)
+			// Since the last update left the files in an incompatible state, the update
+			// should not be picked up by our provider.
+			sCtx, sc := context.WithTimeout(context.Background(), 2*defaultTestRefreshDuration)
+			defer sc()
+			if _, err := distCh.Receive(sCtx); err == nil {
+				t.Fatal("New key material pushed to distributor when underlying files did not change")
+			}
+
+			// The provider should return key material corresponding to the old state.
+			km2, err := prov.KeyMaterial(ctx)
+			if err != nil {
+				t.Fatalf("provider.KeyMaterial() failed: %v", err)
+			}
+			if err := compareKeyMaterial(km1, km2); err != nil {
+				t.Fatalf("Expected provider to not update key material: %v", err)
+			}
+		})
 	}
-	defer prov.Close()
+}
 
-	// Make sure the provider picks up the files and pushes the key material on
-	// to the distributors.
-	ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
+// TestProvider_UpdateFailure_ThenSuccess tests the case where updating cert/key
+// files fail. Verifies that the failed update does not push anything on the
+// distributor. Then the update succeeds, and the test verifies that the key
+// material is updated.
+func (s) TestProvider_UpdateFailureSPIFFE_MissingFile(t *testing.T) {
+	dir, prov, distCh, cancel := initializeProvider(t, "Delete spiffe file being read", true)
 	defer cancel()
-	for i := 0; i < 2; i++ {
-		// Since we have root and identity certs, we need to make sure the
-		// update is pushed on both of them.
-		if _, err := distCh.Receive(ctx); err != nil {
-			t.Fatalf("timeout waiting for provider to read files and push key material to distributor: %v", err)
-		}
-	}
+
+	// Make sure the provider is healthy and returns key material.
+	ctx, cc := context.WithTimeout(context.Background(), defaultTestTimeout)
+	defer cc()
 	km1, err := prov.KeyMaterial(ctx)
 	if err != nil {
 		t.Fatalf("provider.KeyMaterial() failed: %v", err)
 	}
 
-	// Update the symlink to point to dir2.
-	symLinkTmpName := path.Join(tmpdir, "test_symlink.tmp")
-	if err := os.Symlink(dir2, symLinkTmpName); err != nil {
-		t.Fatalf("failed to create symlink to %q: %v", dir2, err)
-	}
-	if err := os.Rename(symLinkTmpName, symLinkName); err != nil {
-		t.Fatalf("failed to update symlink: %v", err)
-	}
+	// Remove the file that we are reading
+	removeTmpFile(t, path.Join(dir, spiffeBundleFile))
 
-	// Make sure the provider picks up the new files and pushes the key material
-	// on to the distributors.
-	for i := 0; i < 2; i++ {
-		// Since we have root and identity certs, we need to make sure the
-		// update is pushed on both of them.
-		if _, err := distCh.Receive(ctx); err != nil {
-			t.Fatalf("timeout waiting for provider to read files and push key material to distributor: %v", err)
-		}
+	// Since the last update left the files in an incompatible state, the update
+	// should not be picked up by our provider.
+	sCtx, sc := context.WithTimeout(context.Background(), 2*defaultTestRefreshDuration)
+	defer sc()
+	if _, err := distCh.Receive(sCtx); err == nil {
+		t.Fatal("new key material pushed to distributor when underlying files did not change")
 	}
+
+	// The provider should return key material corresponding to the old state.
 	km2, err := prov.KeyMaterial(ctx)
 	if err != nil {
 		t.Fatalf("provider.KeyMaterial() failed: %v", err)
 	}
-
-	if err := compareKeyMaterial(km1, km2); err == nil {
-		t.Fatal("expected provider to return new key material after symlink update")
+	if err := compareKeyMaterial(km1, km2); err != nil {
+		t.Fatalf("expected provider to not update key material: %v", err)
 	}
 }
 
@@ -385,8 +595,8 @@ func (s) TestProvider_UpdateSuccessWithSymlink(t *testing.T) {
 // files fail. Verifies that the failed update does not push anything on the
 // distributor. Then the update succeeds, and the test verifies that the key
 // material is updated.
-func (s) TestProvider_UpdateFailure_ThenSuccess(t *testing.T) {
-	dir, prov, distCh, cancel := initializeProvider(t, "update_failure")
+func (s) TestProvider_UpdateFailureRoot_MissingFile(t *testing.T) {
+	dir, prov, distCh, cancel := initializeProvider(t, "Delete root file being read", false)
 	defer cancel()
 
 	// Make sure the provider is healthy and returns key material.
@@ -397,11 +607,8 @@ func (s) TestProvider_UpdateFailure_ThenSuccess(t *testing.T) {
 		t.Fatalf("provider.KeyMaterial() failed: %v", err)
 	}
 
-	// Update only the cert file. The key file is left unchanged. This should
-	// lead to these two files being not compatible with each other. This
-	// simulates the case where the watching goroutine might catch the files in
-	// the midst of an update.
-	createTmpFile(t, testdata.Path("x509/server1_cert.pem"), path.Join(dir, certFile))
+	// Remove the file that we are reading
+	removeTmpFile(t, path.Join(dir, rootFile))
 
 	// Since the last update left the files in an incompatible state, the update
 	// should not be picked up by our provider.
@@ -419,19 +626,4 @@ func (s) TestProvider_UpdateFailure_ThenSuccess(t *testing.T) {
 	if err := compareKeyMaterial(km1, km2); err != nil {
 		t.Fatalf("expected provider to not update key material: %v", err)
 	}
-
-	// Update the key file to match the cert file.
-	createTmpFile(t, testdata.Path("x509/server1_key.pem"), path.Join(dir, keyFile))
-
-	// Make sure update is picked up.
-	if _, err := distCh.Receive(ctx); err != nil {
-		t.Fatal("timeout waiting for new key material to be pushed to the distributor")
-	}
-	km3, err := prov.KeyMaterial(ctx)
-	if err != nil {
-		t.Fatalf("provider.KeyMaterial() failed: %v", err)
-	}
-	if err := compareKeyMaterial(km2, km3); err == nil {
-		t.Fatal("expected provider to return new key material after update to underlying file")
-	}
 }
diff --git a/internal/credentials/spiffe/spiffe_test.go b/internal/credentials/spiffe/spiffe_test.go
index 8419549d..c4ad015d 100644
--- a/internal/credentials/spiffe/spiffe_test.go
+++ b/internal/credentials/spiffe/spiffe_test.go
@@ -25,12 +25,27 @@ import (
 	"os"
 	"testing"
 
+	"github.com/spiffe/go-spiffe/v2/bundle/spiffebundle"
 	"google.golang.org/grpc/testdata"
 )
 
+// loadSPIFFEBundleMap loads a SPIFFE Bundle Map from a file. See the SPIFFE
+// Bundle Map spec for more detail -
+// https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE_Trust_Domain_and_Bundle.md#4-spiffe-bundle-format
+// If duplicate keys are encountered in the JSON parsing, Go's default unmarshal
+// behavior occurs which causes the last processed entry to be the entry in the
+// parsed map.
+func loadSPIFFEBundleMap(filePath string) (map[string]*spiffebundle.Bundle, error) {
+	bundleMapRaw, err := os.ReadFile(filePath)
+	if err != nil {
+		return nil, err
+	}
+	return BundleMapFromBytes(bundleMapRaw)
+}
+
 func TestKnownSPIFFEBundle(t *testing.T) {
 	spiffeBundleFile := testdata.Path("spiffe/spiffebundle.json")
-	bundles, err := LoadSPIFFEBundleMap(spiffeBundleFile)
+	bundles, err := loadSPIFFEBundleMap(spiffeBundleFile)
 	if err != nil {
 		t.Fatalf("LoadSPIFFEBundleMap(%v) Error during parsing: %v", spiffeBundleFile, err)
 	}
@@ -85,7 +100,7 @@ func TestLoadSPIFFEBundleMapFailures(t *testing.T) {
 	}
 	for _, path := range filePaths {
 		t.Run(path, func(t *testing.T) {
-			if _, err := LoadSPIFFEBundleMap(path); err == nil {
+			if _, err := loadSPIFFEBundleMap(path); err == nil {
 				t.Fatalf("LoadSPIFFEBundleMap(%v) did not fail but should have.", path)
 			}
 		})
@@ -97,7 +112,7 @@ func TestLoadSPIFFEBundleMapX509Failures(t *testing.T) {
 	// use other than this is specified, the parser does not fail, it
 	// just doesn't add an x509 authority or jwt authority to the bundle
 	filePath := testdata.Path("spiffe/spiffebundle_wrong_use.json")
-	bundle, err := LoadSPIFFEBundleMap(filePath)
+	bundle, err := loadSPIFFEBundleMap(filePath)
 	if err != nil {
 		t.Fatalf("LoadSPIFFEBundleMap(%v) failed with error: %v", filePath, err)
 	}
diff --git a/testdata/spiffe/README.md b/testdata/spiffe/README.md
index 8e4fb641..eb834f03 100644
--- a/testdata/spiffe/README.md
+++ b/testdata/spiffe/README.md
@@ -7,6 +7,9 @@
 *   spiffe-openssl.cnf - the configuration file passed to the openssl CLI when
     creating these certificate files
 *   spiffebundle.json - the valid spiffe bundle for happy path testing
+*   spiffebundle2.json - Another valid spiffe bundle that is used in testing
+    file reloading (a different file is needed to ensure changes are picked up).
+    It is just the `example.com` trust domain from spiffebundle.json.
 *   spiffebundle_corrupted_cert.json - manually modifies the `x5c` field and
     removes a character to create an invalid certificate
 *   spiffebundle_invalid_trustdomain - uses a `#` in the trust domain which is a
diff --git a/testdata/spiffe/spiffebundle2.json b/testdata/spiffe/spiffebundle2.json
new file mode 100644
index 00000000..69a7ba05
--- /dev/null
+++ b/testdata/spiffe/spiffebundle2.json
@@ -0,0 +1,18 @@
+{
+    "trust_domains": {
+        "example.com": {
+            "spiffe_sequence": 12035488,
+            "keys": [
+                {
+                    "kty": "RSA",
+                    "use": "x509-svid",
+                    "x5c": [
+                        "MIIFsjCCA5qgAwIBAgIURygVMMzdr+Q7rsUaz189JozyHMwwDQYJKoZIhvcNAQELBQAwTjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQwwCgYDVQQHDANTVkwxDTALBgNVBAoMBGdSUEMxFTATBgNVBAMMDHRlc3QtY2xpZW50MTAeFw0yMTEyMjMxODQyNTJaFw0zMTEyMjExODQyNTJaME4xCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEMMAoGA1UEBwwDU1ZMMQ0wCwYDVQQKDARnUlBDMRUwEwYDVQQDDAx0ZXN0LWNsaWVudDEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDJ4AqpGetyVSqGUuBJLVFla+7bEfca7UYzfVSSZLZ/X+JDmWIVN8UIPuFib5jhMEc3XaUnFXUmM7zEtz/ZG5hapwLwOb2C3ZxOP6PQjYCJxbkLie+b43UQrFu1xxd3vMhVJgcj/AIxEpmszuqOa6kUrkYifjJADQ+64kZgl66bsTdXMCzpxyFl9xUfff59L8OX+HUfAcoZz3emjg3ZJPYURQEmjdZTOau1EjFilwHgd989Jt7NKgx30NXoHmw7nusVBIY94fL2VKN3f1XVm0dHu5NI279Q6zr0ZBU7k5T3IeHnzsUesQS4NGlklDWoVTKk73Uv9Pna8yQsSW757PEbHOGp9Knu4bnoGPOlsG81yIPipO6hTgGFK24pF97M9kpGbWqYX4+2vLlrCAfcmsHqaUPmQlYeRVTT6vw7ctYo2kyUYGtnODXk76LqewRBVvkzx75QUhfjAyb740YcDmIenc56Tq6gebJHjhEmVSehR6xIpXP7SVeurTyhPsEQnpJHtgs4dcwWOZp7BvPNzHXmJqfr7vsshie3vS5kQ0u1e1yqAqXgyDjqKXOkx+dpgUTehSJHhPNHvTc5LXRsvvXKYz6FrwR/DZ8t7BNEvPeLjFgxpH7QVJFLCvCbXs5K6yYbsnLfxFIBPRnrbJkIsK+sQwnRdnsiUdPsTkG5B2lQfQIDAQABo4GHMIGEMB0GA1UdDgQWBBQ2lBp0PiRHHvQ5IRURm8aHsj4RETAfBgNVHSMEGDAWgBQ2lBp0PiRHHvQ5IRURm8aHsj4RETAPBgNVHRMBAf8EBTADAQH/MDEGA1UdEQQqMCiGJnNwaWZmZTovL2Zvby5iYXIuY29tL2NsaWVudC93b3JrbG9hZC8xMA0GCSqGSIb3DQEBCwUAA4ICAQA1mSkgRclAl+E/aS9zJ7t8+Y4n3T24nOKKveSIjxXm/zjhWqVsLYBI6kglWtih2+PELvU8JdPqNZK34Kl0Q6FWpVSGDdWN1i6NyORt2ocggL3ke3iXxRk3UpUKJmqwz81VhA2KUHnMlyE0IufFfZNwNWWHBv13uJfRbjeQpKPhU+yf4DeXrsWcvrZlGvAET+mcplafUzCp7Iv+PcISJtUerbxbVtuHVeZCLlgDXWkLAWJN8rf0dIG4x060LJ+j6j9uRVhb9sZn1HJV+j4XdIYm1VKilluhOtNwP2d3Ox/JuTBxf7hFHXZPfMagQE5k5PzmxRaCAEMJ1l2DvUbZw+shJfSNoWcBo2qadnUaWT3BmmJRBDh7ZReib/RQ1Rd4ygOyzP3E0vkV4/gqyjLdApXh5PZP8KLQZ+1JN/sdWt7VfIt9wYOpkIqujdll51ESHzwQeAK9WVCB4UvVz6zdhItB9CRbXPreWC+wCB1xDovIzFKOVsLs5+Gqs1m7VinG2LxbDqaKyo/FB0Hxx0acBNzezLWoDwXYQrN0T0S4pnqhKD1CYPpdArBkNezUYAjS725FkApuK+mnBX3U0msBffEaUEOkcyar1EW2m/33vpetD/k3eQQkmvQf4Hbiu9AF+9cNDm/hMuXEw5EXGA91fn0891b5eEW8BJHXX0jri0aN8g=="
+                    ],
+                    "n": "yeAKqRnrclUqhlLgSS1RZWvu2xH3Gu1GM31UkmS2f1_iQ5liFTfFCD7hYm-Y4TBHN12lJxV1JjO8xLc_2RuYWqcC8Dm9gt2cTj-j0I2AicW5C4nvm-N1EKxbtccXd7zIVSYHI_wCMRKZrM7qjmupFK5GIn4yQA0PuuJGYJeum7E3VzAs6cchZfcVH33-fS_Dl_h1HwHKGc93po4N2ST2FEUBJo3WUzmrtRIxYpcB4HffPSbezSoMd9DV6B5sO57rFQSGPeHy9lSjd39V1ZtHR7uTSNu_UOs69GQVO5OU9yHh587FHrEEuDRpZJQ1qFUypO91L_T52vMkLElu-ezxGxzhqfSp7uG56BjzpbBvNciD4qTuoU4BhStuKRfezPZKRm1qmF-Ptry5awgH3JrB6mlD5kJWHkVU0-r8O3LWKNpMlGBrZzg15O-i6nsEQVb5M8e-UFIX4wMm--NGHA5iHp3Oek6uoHmyR44RJlUnoUesSKVz-0lXrq08oT7BEJ6SR7YLOHXMFjmaewbzzcx15ian6-77LIYnt70uZENLtXtcqgKl4Mg46ilzpMfnaYFE3oUiR4TzR703OS10bL71ymM-ha8Efw2fLewTRLz3i4xYMaR-0FSRSwrwm17OSusmG7Jy38RSAT0Z62yZCLCvrEMJ0XZ7IlHT7E5BuQdpUH0",
+                    "e": "AQAB"
+                }
+            ]
+        }
+    }
+}
diff --git a/xds/internal/xdsclient/pool/pool_test.go b/xds/internal/xdsclient/pool/pool_test.go
new file mode 100644
index 00000000..b12cc737
--- /dev/null
+++ b/xds/internal/xdsclient/pool/pool_test.go
@@ -0,0 +1,88 @@
+/*
+ *
+ * Copyright 2025 gRPC authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package pool_test
+
+import (
+	"encoding/json"
+	"fmt"
+	"testing"
+
+	"github.com/google/uuid"
+	"google.golang.org/grpc/internal/grpctest"
+	"google.golang.org/grpc/internal/testutils"
+	"google.golang.org/grpc/internal/testutils/stats"
+	"google.golang.org/grpc/internal/xds/bootstrap"
+	"google.golang.org/grpc/xds/internal/xdsclient"
+)
+
+type s struct {
+	grpctest.Tester
+}
+
+func Test(t *testing.T) {
+	grpctest.RunSubTests(t, s{})
+}
+
+// TestDefaultPool_LazyLoadBootstrapConfig verifies that the DefaultPool
+// lazily loads the bootstrap configuration from environment variables when
+// an xDS client is created for the first time.
+//
+// If tries to create the new client in DefaultPool at the start of test when
+// none of the env vars are set. This should fail.
+//
+// Then it sets the env var XDSBootstrapFileName and retry creating a client
+// in DefaultPool. This should succeed.
+func (s) TestDefaultPool_LazyLoadBootstrapConfig(t *testing.T) {
+	_, closeFunc, err := xdsclient.DefaultPool.NewClient(t.Name(), &stats.NoopMetricsRecorder{})
+	if err == nil {
+		t.Fatalf("xdsclient.DefaultPool.NewClient() succeeded without setting bootstrap config env vars, want failure")
+	}
+
+	bs, err := bootstrap.NewContentsForTesting(bootstrap.ConfigOptionsForTesting{
+		Servers: []byte(fmt.Sprintf(`[{
+			 "server_uri": %q,
+			 "channel_creds": [{"type": "insecure"}]
+		 }]`, "non-existent-management-server")),
+		Node: []byte(fmt.Sprintf(`{"id": "%s"}`, uuid.New().String())),
+		CertificateProviders: map[string]json.RawMessage{
+			"cert-provider-instance": json.RawMessage("{}"),
+		},
+	})
+	if err != nil {
+		t.Fatalf("Failed to create bootstrap configuration: %v", err)
+	}
+
+	testutils.CreateBootstrapFileForTesting(t, bs)
+	if cfg := xdsclient.DefaultPool.BootstrapConfigForTesting(); cfg != nil {
+		t.Fatalf("DefaultPool.BootstrapConfigForTesting() = %v, want nil", cfg)
+	}
+
+	_, closeFunc, err = xdsclient.DefaultPool.NewClient(t.Name(), &stats.NoopMetricsRecorder{})
+	if err != nil {
+		t.Fatalf("Failed to create xDS client: %v", err)
+	}
+	defer func() {
+		closeFunc()
+		xdsclient.DefaultPool.UnsetBootstrapConfigForTesting()
+	}()
+
+	if xdsclient.DefaultPool.BootstrapConfigForTesting() == nil {
+		t.Fatalf("DefaultPool.BootstrapConfigForTesting() = nil, want non-nil")
+	}
+}
diff --git a/xds/server_test.go b/xds/server_test.go
index 0b55f10f..51579333 100644
--- a/xds/server_test.go
+++ b/xds/server_test.go
@@ -178,7 +178,7 @@ func (s) TestNewServer_Failure(t *testing.T) {
 		{
 			desc:       "bootstrap env var not set",
 			serverOpts: []grpc.ServerOption{grpc.Creds(xdsCreds), BootstrapContentsForTesting(nil)},
-			wantErr:    "bootstrap configuration not set in the pool",
+			wantErr:    "failed to read xDS bootstrap config from env vars",
 		},
 		{
 			desc: "empty bootstrap config",