diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go
index b284284218a9d..9b99ec45887c7 100644
--- a/lib/web/apiserver.go
+++ b/lib/web/apiserver.go
@@ -926,6 +926,9 @@ func (h *Handler) bindDefaultEndpoints() {
h.GET(OIDCJWKWURI, h.WithLimiter(h.jwksOIDC))
h.GET("/webapi/thumbprint", h.WithLimiter(h.thumbprint))
+ // SPIFFE Federation Trust Bundle
+ h.GET("/webapi/spiffe/bundle.json", h.WithLimiter(h.getSPIFFEBundle))
+
// DiscoveryConfig CRUD
h.GET("/webapi/sites/:site/discoveryconfig", h.WithClusterAuth(h.discoveryconfigList))
h.POST("/webapi/sites/:site/discoveryconfig", h.WithClusterAuth(h.discoveryconfigCreate))
diff --git a/lib/web/spiffe.go b/lib/web/spiffe.go
new file mode 100644
index 0000000000000..a47e938c51387
--- /dev/null
+++ b/lib/web/spiffe.go
@@ -0,0 +1,93 @@
+// Teleport
+// Copyright (C) 2024 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package web
+
+import (
+ "net/http"
+ "time"
+
+ "github.com/gravitational/trace"
+ "github.com/julienschmidt/httprouter"
+ "github.com/spiffe/go-spiffe/v2/bundle/spiffebundle"
+ "github.com/spiffe/go-spiffe/v2/spiffeid"
+
+ "github.com/gravitational/teleport/api/types"
+ "github.com/gravitational/teleport/lib/services"
+ "github.com/gravitational/teleport/lib/tlsca"
+)
+
+// getSPIFFEBundle returns the SPIFFE-compatible trust bundle which allows other
+// trust domains to federate with this Teleport cluster.
+//
+// Mounted at /webapi/spiffe/bundle.json
+//
+// Must abide by the standard for a "https_web" profile as described in
+// https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE_Federation.md#5-serving-and-consuming-a-spiffe-bundle-endpoint
+func (h *Handler) getSPIFFEBundle(w http.ResponseWriter, r *http.Request, _ httprouter.Params) (any, error) {
+ cn, err := h.GetAccessPoint().GetClusterName()
+ if err != nil {
+ return nil, trace.Wrap(err, "fetching cluster name")
+ }
+
+ td, err := spiffeid.TrustDomainFromString(cn.GetClusterName())
+ if err != nil {
+ return nil, trace.Wrap(err, "creating trust domain")
+ }
+
+ bundle := spiffebundle.New(td)
+ // The refresh hint indicates how often a federated trust domain should
+ // check for updates to the bundle. This should be a low value to ensure
+ // that CA rotations are picked up quickly. Since we're leveraging
+ // https_web, it's not critical for a federated trust domain to catch
+ // all phases of the rotation - however, if we support https_spiffe in
+ // future, we may need to consider a lower value or enforcing a wait
+ // period during rotations equivalent to the refresh hint.
+ bundle.SetRefreshHint(5 * time.Minute)
+ // TODO(noah):
+ // For now, we omit the SequenceNumber field. This is only a SHOULD not a
+ // MUST per the spec. To add this, we will add a sequence number to the
+ // cert authority and increment it on every update.
+
+ const loadKeysFalse = false
+ spiffeCA, err := h.GetAccessPoint().GetCertAuthority(r.Context(), types.CertAuthID{
+ Type: types.SPIFFECA,
+ DomainName: cn.GetClusterName(),
+ }, loadKeysFalse)
+ if err != nil {
+ return nil, trace.Wrap(err, "fetching SPIFFE CA")
+ }
+
+ for _, certPEM := range services.GetTLSCerts(spiffeCA) {
+ cert, err := tlsca.ParseCertificatePEM(certPEM)
+ if err != nil {
+ return nil, trace.Wrap(err, "parsing certificate")
+ }
+ bundle.AddX509Authority(cert)
+ }
+
+ bundleBytes, err := bundle.Marshal()
+ if err != nil {
+ return nil, trace.Wrap(err, "marshaling bundle")
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ if _, err = w.Write(bundleBytes); err != nil {
+ h.logger.DebugContext(h.cfg.Context, "Failed to write SPIFFE bundle response", "error", err)
+ }
+ return nil, nil
+}
diff --git a/lib/web/spiffe_test.go b/lib/web/spiffe_test.go
new file mode 100644
index 0000000000000..7d04c80ad7465
--- /dev/null
+++ b/lib/web/spiffe_test.go
@@ -0,0 +1,71 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package web
+
+import (
+ "context"
+ "crypto/x509"
+ "testing"
+
+ "github.com/gravitational/roundtrip"
+ "github.com/spiffe/go-spiffe/v2/bundle/spiffebundle"
+ "github.com/spiffe/go-spiffe/v2/spiffeid"
+ "github.com/stretchr/testify/require"
+
+ "github.com/gravitational/teleport/api/types"
+ "github.com/gravitational/teleport/lib/client"
+ "github.com/gravitational/teleport/lib/services"
+ "github.com/gravitational/teleport/lib/tlsca"
+)
+
+func TestGetSPIFFEBundle(t *testing.T) {
+ ctx := context.Background()
+ env := newWebPack(t, 1)
+ authServer := env.server.Auth()
+ cn, err := authServer.GetClusterName()
+ require.NoError(t, err)
+ ca, err := authServer.GetCertAuthority(ctx, types.CertAuthID{
+ Type: types.SPIFFECA,
+ DomainName: cn.GetClusterName(),
+ }, false)
+ require.NoError(t, err)
+
+ var wantCACerts []*x509.Certificate
+ for _, certPem := range services.GetTLSCerts(ca) {
+ cert, err := tlsca.ParseCertificatePEM(certPem)
+ require.NoError(t, err)
+ wantCACerts = append(wantCACerts, cert)
+ }
+
+ clt, err := client.NewWebClient(env.proxies[0].webURL.String(), roundtrip.HTTPClient(client.NewInsecureWebClient()))
+ require.NoError(t, err)
+
+ res, err := clt.Get(ctx, clt.Endpoint("webapi", "spiffe", "bundle.json"), nil)
+ require.NoError(t, err)
+
+ td, err := spiffeid.TrustDomainFromString(cn.GetClusterName())
+ require.NoError(t, err)
+ gotBundle, err := spiffebundle.Read(td, res.Reader())
+ require.NoError(t, err)
+
+ require.Len(t, gotBundle.X509Authorities(), len(wantCACerts))
+ for _, caCert := range wantCACerts {
+ require.True(t, gotBundle.HasX509Authority(caCert), "certificate not found in bundle")
+ }
+}