diff --git a/spring-cloud-config-server/pom.xml b/spring-cloud-config-server/pom.xml
index 6d686260..db8efbdc 100644
--- a/spring-cloud-config-server/pom.xml
+++ b/spring-cloud-config-server/pom.xml
@@ -236,6 +236,11 @@
spring-core-test
test
+
+ org.testcontainers
+ vault
+ test
+
diff --git a/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/vault/SpringVaultClientConfiguration.java b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/vault/SpringVaultClientConfiguration.java
index 0eee4427..1f473b48 100644
--- a/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/vault/SpringVaultClientConfiguration.java
+++ b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/vault/SpringVaultClientConfiguration.java
@@ -25,12 +25,14 @@ import org.apache.commons.logging.LogFactory;
import org.springframework.cloud.config.server.environment.ConfigTokenProvider;
import org.springframework.cloud.config.server.environment.VaultEnvironmentProperties;
import org.springframework.cloud.config.server.environment.VaultEnvironmentProperties.AuthenticationMethod;
+import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.Resource;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.util.StringUtils;
import org.springframework.vault.VaultException;
import org.springframework.vault.authentication.ClientAuthentication;
+import org.springframework.vault.authentication.SessionManager;
import org.springframework.vault.client.RestTemplateBuilder;
import org.springframework.vault.client.VaultClients;
import org.springframework.vault.client.VaultEndpoint;
@@ -99,6 +101,28 @@ public class SpringVaultClientConfiguration extends AbstractVaultConfiguration {
return restTemplateBuilder;
}
+ /*
+ * We provide our own SessionManager bean here overriding what is done in {@link
+ * AbstractVaultConfiguration} because when a Vault token is provided via a HTTP
+ * header (meaning our ConfigTokenProvider is an instance of {@link
+ * HttpRequestConfigTokenProvider}). If we don't then {@link
+ * AbstractVaultConfiguration} will create an instance of {@link
+ * LifecycleAwareSessionManager} which persists the token until it is revoked. This
+ * means any subsequent requests to the config server will use whatever token is
+ * persisted in {@link LifecycleAwareSessionManager} and not what is set in the
+ * header. By using {@link StatelessSessionManager} when a token is provided via a
+ * header, we ensure that the token we use to make the request to Vault is the one
+ * provided in the header.
+ */
+ @Override
+ @Bean
+ public SessionManager sessionManager() {
+ if (vaultProperties.getAuthentication() == null && !StringUtils.hasText(vaultProperties.getToken())) {
+ return new StatelessSessionManager(clientAuthentication());
+ }
+ return super.sessionManager();
+ }
+
@Override
public SslConfiguration sslConfiguration() {
if (vaultProperties.isSkipSslValidation()) {
diff --git a/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/vault/StatelessSessionManager.java b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/vault/StatelessSessionManager.java
new file mode 100644
index 00000000..d8d54e57
--- /dev/null
+++ b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/vault/StatelessSessionManager.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2018-2025 the original author or 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
+ *
+ * https://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.springframework.cloud.config.server.environment.vault;
+
+import java.util.concurrent.locks.ReentrantLock;
+
+import org.springframework.util.Assert;
+import org.springframework.vault.authentication.ClientAuthentication;
+import org.springframework.vault.authentication.SessionManager;
+import org.springframework.vault.support.VaultToken;
+
+/**
+ * @author Ryan Baxter
+ */
+public class StatelessSessionManager implements SessionManager {
+
+ private final ClientAuthentication clientAuthentication;
+
+ private final ReentrantLock lock = new ReentrantLock();
+
+ public StatelessSessionManager(ClientAuthentication clientAuthentication) {
+ Assert.notNull(clientAuthentication, "ClientAuthentication must not be null");
+ this.clientAuthentication = clientAuthentication;
+ }
+
+ public VaultToken getSessionToken() {
+ this.lock.lock();
+ try {
+ return this.clientAuthentication.login();
+ }
+ finally {
+ this.lock.unlock();
+ }
+ }
+
+}
diff --git a/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/environment/vault/StatelessSessionManagerTests.java b/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/environment/vault/StatelessSessionManagerTests.java
new file mode 100644
index 00000000..fbc98446
--- /dev/null
+++ b/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/environment/vault/StatelessSessionManagerTests.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2018-2025 the original author or 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
+ *
+ * https://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.springframework.cloud.config.server.environment.vault;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.vault.authentication.ClientAuthentication;
+import org.springframework.vault.support.VaultToken;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * @author Ryan Baxter
+ */
+class StatelessSessionManagerTests {
+
+ @Test
+ public void verifyClientAuthenticationIsCalledEveryTime() {
+ ClientAuthentication clientAuthentication = mock(ClientAuthentication.class);
+ when(clientAuthentication.login()).thenReturn(VaultToken.of("mytoken"), VaultToken.of("anothertoken"));
+ StatelessSessionManager sessionManager = new StatelessSessionManager(clientAuthentication);
+ assertThat(sessionManager.getSessionToken().getToken()).isEqualTo("mytoken");
+ assertThat(sessionManager.getSessionToken().getToken()).isEqualTo("anothertoken");
+ verify(clientAuthentication, times(2)).login();
+ }
+
+}
diff --git a/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/environment/vault/VaultIntegrationTests.java b/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/environment/vault/VaultIntegrationTests.java
new file mode 100644
index 00000000..366e1639
--- /dev/null
+++ b/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/environment/vault/VaultIntegrationTests.java
@@ -0,0 +1,145 @@
+/*
+ * Copyright 2018-2025 the original author or 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
+ *
+ * https://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.springframework.cloud.config.server.environment.vault;
+
+import java.io.IOException;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.testcontainers.containers.BindMode;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.vault.VaultContainer;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.web.client.RestTemplateBuilder;
+import org.springframework.cloud.config.environment.Environment;
+import org.springframework.cloud.config.server.test.TestConfigServerApplication;
+import org.springframework.context.ConfigurableApplicationContext;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.util.TestSocketUtils;
+import org.springframework.vault.authentication.LifecycleAwareSessionManager;
+import org.springframework.vault.authentication.SessionManager;
+import org.springframework.web.client.RestTemplate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * @author Ryan Baxter
+ */
+@Testcontainers
+public class VaultIntegrationTests {
+
+ private static final String VAULT_TOKEN = "myroot";
+
+ private static final String ROLE_ID = "testroleid";
+
+ private static final String SECRET_ID = "testsecretid";
+
+ private static final int configServerPort = TestSocketUtils.findAvailableTcpPort();
+
+ @Container
+ public static VaultContainer> vaultContainer = new VaultContainer<>("hashicorp/vault:1.13.3")
+ .withVaultToken(VAULT_TOKEN)
+ .withClasspathResourceMapping("vault/vault_test_policy.txt", "/tmp/vault_test_policy.txt", BindMode.READ_ONLY);
+
+ @BeforeAll
+ public static void before() throws IOException, InterruptedException {
+ execInVault("vault policy write full_access /tmp/vault_test_policy.txt".split(" "));
+ execInVault("vault auth enable approle".split(" "));
+ execInVault(("vault write /auth/approle/role/example-app policies=full_access role_id=" + ROLE_ID).split(" "));
+ execInVault(("vault write /auth/approle/role/example-app/custom-secret-id secret_id=" + SECRET_ID).split(" "));
+ execInVault("vault kv put secret/application foo=bar baz=bam".split(" "));
+ execInVault("vault kv put secret/myapp foo=myappsbar".split(" "));
+ }
+
+ private static void execInVault(String... command) throws IOException, InterruptedException {
+ org.testcontainers.containers.Container.ExecResult execResult = vaultContainer.execInContainer(command);
+ assertThat(execResult.getExitCode()).isZero();
+ assertThat(execResult.getStderr()).isEmpty();
+ }
+
+ @Test
+ public void useStatelessLifecycleManager() {
+ try (ConfigurableApplicationContext server = SpringApplication.run(
+ new Class[] { TestConfigServerApplication.class },
+
+ new String[] { "--spring.config.name=server", "--spring.profiles.active=vault",
+ "--server.port=" + configServerPort,
+ "--spring.cloud.config.server.vault.port=" + vaultContainer.getFirstMappedPort(),
+ "--spring.cloud.config.server.vault.kv-version=2",
+ "--logging.level.org.springframework.cloud.config.server.environment=DEBUG",
+ "--debug=true" })) {
+ RestTemplate rest = new RestTemplateBuilder().build();
+ String configServerUrl = "http://localhost:" + configServerPort;
+ HttpHeaders headers = new HttpHeaders();
+ headers.set("X-Config-Token", VAULT_TOKEN);
+ HttpEntity entity = new HttpEntity<>(headers);
+ ResponseEntity env = rest.exchange(configServerUrl + "/myapp/default",
+ org.springframework.http.HttpMethod.GET, entity, Environment.class);
+ assertThat(server.getBean(SessionManager.class)).isInstanceOf(StatelessSessionManager.class);
+ assertThat(env.getBody().getPropertySources().get(0).getSource().get("foo")).isEqualTo("myappsbar");
+ }
+ }
+
+ @Test
+ public void useLifecycleManagerWithAuthentication() {
+ try (ConfigurableApplicationContext server = SpringApplication
+ .run(new Class[] { TestConfigServerApplication.class }, new String[] { "--spring.config.name=server",
+ "--spring.profiles.active=vault", "--server.port=" + configServerPort,
+ "--spring.cloud.config.server.vault.port=" + vaultContainer.getFirstMappedPort(),
+ "--spring.cloud.config.server.vault.kv-version=2",
+ "--spring.cloud.config.server.vault.authentication=APPROLE",
+ "--spring.cloud.config.server.vault.app-role.role-id=" + ROLE_ID,
+ "--spring.cloud.config.server.vault.app-role.secret-id=" + SECRET_ID,
+ "--logging.level.org.springframework.cloud.config.server.environment=DEBUG", "--debug=true" })) {
+ RestTemplate rest = new RestTemplateBuilder().build();
+ String configServerUrl = "http://localhost:" + configServerPort;
+ HttpHeaders headers = new HttpHeaders();
+ headers.set("X-Config-Token", VAULT_TOKEN);
+ HttpEntity entity = new HttpEntity<>(headers);
+ ResponseEntity env = rest.exchange(configServerUrl + "/myapp/default",
+ org.springframework.http.HttpMethod.GET, entity, Environment.class);
+ assertThat(server.getBean(SessionManager.class)).isInstanceOf(LifecycleAwareSessionManager.class);
+ assertThat(env.getBody().getPropertySources().get(0).getSource().get("foo")).isEqualTo("myappsbar");
+ }
+ }
+
+ @Test
+ public void useLifecycleManagerWithToken() {
+ try (ConfigurableApplicationContext server = SpringApplication
+ .run(new Class[] { TestConfigServerApplication.class }, new String[] { "--spring.config.name=server",
+ "--spring.profiles.active=vault", "--server.port=" + configServerPort,
+ "--spring.cloud.config.server.vault.port=" + vaultContainer.getFirstMappedPort(),
+ "--spring.cloud.config.server.vault.kv-version=2",
+ "--spring.cloud.config.server.vault.token=" + VAULT_TOKEN,
+ "--logging.level.org.springframework.cloud.config.server.environment=DEBUG", "--debug=true" })) {
+ RestTemplate rest = new RestTemplateBuilder().build();
+ String configServerUrl = "http://localhost:" + configServerPort;
+ HttpHeaders headers = new HttpHeaders();
+ headers.set("X-Config-Token", VAULT_TOKEN);
+ HttpEntity entity = new HttpEntity<>(headers);
+ ResponseEntity env = rest.exchange(configServerUrl + "/myapp/default",
+ org.springframework.http.HttpMethod.GET, entity, Environment.class);
+ assertThat(server.getBean(SessionManager.class)).isInstanceOf(LifecycleAwareSessionManager.class);
+ assertThat(env.getBody().getPropertySources().get(0).getSource().get("foo")).isEqualTo("myappsbar");
+ }
+ }
+
+}
diff --git a/spring-cloud-config-server/src/test/resources/vault/vault_test_policy.txt b/spring-cloud-config-server/src/test/resources/vault/vault_test_policy.txt
new file mode 100644
index 00000000..283d40f7
--- /dev/null
+++ b/spring-cloud-config-server/src/test/resources/vault/vault_test_policy.txt
@@ -0,0 +1,16 @@
+path "secret/metadata/" {
+capabilities = ["list"]
+}
+path "secret/data/application,*" {
+capabilities = ["read", "list"]
+}
+path "secret/data/application*" {
+capabilities = ["read", "list"]
+}
+path "secret/data/myapp*" {
+capabilities = ["read", "list"]
+}
+path "secret/data/myapp,*" {
+capabilities = ["read", "list"]
+}
+