1. Introduction
In this tutorial, we’ll explore different ways to access secrets stored in Hashicorp’s Vault from an application running on Kubernetes.
2. Quick Recap
We’ve already covered Hashicorp’s Vault in earlier tutorials, where we’ve shown how to install and populate it with secrets. In a nutshell, Vault provides a secure storage service for application secrets, which can be either static or dynamically generated.
To access Vault services, an application must authenticate itself using one of the available mechanisms. When the application runs in a Kubernetes environment, Vault can authenticate it based on its associated service account, thus eliminating the need for separate credentials.
In this scenario, the Kubernetes service account is bound to a Vault role, which defines the associated access policy. This policy defines which secrets an application can have access to.
3. Providing Secrets to Applications
In Kubernetes environments, a developer has multiple options to get a Vault-managed secret, which can be classified as more or less intrusive. “Intrusive”, in this context, relates to the level of awareness the application has about the origin of the secrets.
Here’s a summary of the methods we’ll cover:
- Explicit retrieval using Vault’s API
- Semi-explicit retrieval using Spring Boot’s Vault support
- Transparent support using Vault Sidecar
- Transparent support using Vault Secret CSI Provider
- Transparent support using Vault Secret Operator
4. Authentication Setup
In all those methods, the test application will use Kubernetes authentication to access Vault’s API. When running inside Kubernetes, this would be automatically provided. However, to use this kind of authentication from outside the cluster, we need a valid token associated with a service account.
One way to achieve this is to create a service account token secret. Secrets and service accounts are namespace-scoped resources, so let’s start by creating a namespace to hold them:
$ kubectl create namespace baeldung
Next, we create the service account:
$ kubectl create serviceaccount --namespace baeldung vault-test-sa
Finally, let’s generate a token valid for 24 hours and save it to a file:
$ kubectl create token --namespace baeldung vault-test-sa --duration 24h > sa-token.txt
Now, we need to bind the Kubernetes service account with a Vault role:
$ vault write auth/kubernetes/role/baeldung-test-role \
bound_service_account_names=vault-test-sa \
bound_service_account_namespaces=baeldung \
policies=default,baeldung-test-policy \
ttl=1h
5. Explicit Retrieval
In this scenario, the application gets the required secrets directly using Vault’s REST API or, more likely, using one of the available libraries. For Java, we’ll use the library from the spring-vault project, which leverages the Spring Framework for low-level REST operations:
<dependency>
<groupId>org.springframework.vault</groupId>
<artifactId>spring-vault-core</artifactId>
<version>3.1.1</version>
</dependency>
The latest version of this dependency is available on Maven Central.
Please make sure to pick a version that is compatible with Spring Framework’s main version: spring-vault-core 3.x requires Spring 6.x, while spring-vault-core 2.x requires Spring 5.3.x.
The main entry point to access Vault’a API is the VaultTemplate class. The library provides the EnvironmentVaultConfiguration helper class that simplifies the process of configuring a VaultTemplate instance with the required access and authentication details. To use it, the recommended way is to import it from one of the @Configuration classes of our application:
@Configuration
@PropertySource("vault-config-k8s.properties")
@Import(EnvironmentVaultConfiguration.class)
public class VaultConfig {
// No code!
}
In this case, we’re also adding the vault-config-k8s property source, where we’ll add the required connection details. At a minimum, we need to inform Vault’s endpoint URI and authentication mechanism to use. Since we’ll be running our application outside of a cluster during development, we also need to supply the location of the file holding the service account token:
vault.uri=http://localhost:8200
vault.authentication=KUBERNETES
vault.kubernetes.role=baeldung-test-role
vault.kubernetes.service-account-token-file=sa-token.txt
We can now inject a VaultTemplate wherever we need to access Vault’s API. As a quick example, let’s create a CommandLineRunner @Bean that lists the contents of all secrets:
@Bean
CommandLineRunner listSecrets(VaultTemplate vault) {
return args -> {
VaultKeyValueOperations ops = vault.opsForKeyValue("secrets", VaultKeyValueOperationsSupport.KeyValueBackend.KV_2);
List<String> secrets = ops.list("");
if (secrets == null) {
System.out.println("No secrets found");
return;
}
secrets.forEach(s -> {
System.out.println("secret=" + s);
var response = ops.get(s);
var data = response.getRequiredData();
data.entrySet()
.forEach(e -> {
System.out.println("- key=" + e.getKey() + " => " + e.getValue());
});
});
};
}
In our case, Vault has a KV Version 2 secrets engine mounted on /secrets path, so we’ve used the opsForKeyValue method to get a VaultKeyValueOperations object that we’ll use to list all secrets. Other secret engines also have dedicated operations objects that offer tailor-made methods to access them.
For secret engines that do not have a dedicated VaultXYZOperations façade, we can use generic methods to access any path:
- read(path): Reads data from the specified path
- write(path, data): Writes data at the specified path
- list(path): Returns a list of entries under the specified path
- delete(path): Remove the secret at the specified path
6. Semi-Explicit Retrieval
In the previous method, the fact that we’re directly accessing Vault’a API introduces a strong coupling that may pose a few hurdles. For instance, this means developers will need a Vault instance or create mocks during development time and when running CI pipelines.
Alternatively, we can use the Spring Cloud Vault library in our projects to make Vault’s secret lookup transparent to the application’s code. This library makes this possible by exposing a custom PropertySource to Spring, which will be picked up and configured during the application bootstrap.
We call this method “semi-explicit” because, while it is true that the application’s code is unaware of Vault’s usage, we still must add the required dependencies to the project. The easiest way to achieve this is by using the available starter library:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-vault-config</artifactId>
<version>4.1.1</version>
</dependency>
The latest version of this dependency is available on Maven Central.
As before, we must pick a version compatible with Spring Boot’s main version used by our project. Spring Boot 2.7.x requires version 3.1.x, while Spring Boot 3.x requires version 4.x.
To enable Vault as a property source, we must add a few configuration properties. A common practice is to use a dedicated Spring profile for this, which allows us to switch from Vault-based secrets to any other source quickly.
For Kubernetes, this is what a typical configuration properties file looks like:
spring.config.import=vault://
spring.cloud.vault.uri=http://vault-internal.vault.svc.cluster.local:8200
spring.cloud.vault.authentication=KUBERNETES
spring.cloud.vault.kv.backend=secrets
spring.cloud.vault.kv.application-name=baeldung-test
This configuration enables Vault’s KV backend to be mounted at the secrets path on the server. The library will use the configured application name as the path under this backend from where to pick secrets.
The spring.config.import property is also needed to enable Vault as a property source. Please notice this property was introduced in Spring Boot 2.4, along with the deprecation of the bootstrap context initialization. When migrating applications based on older versions of Spring Boot, this is something to pay special attention to.
The complete list of available configuration properties is available on Spring Cloud Vault’s documentation.
Now, let’s show how to use this with a simple example that gets a configuration value from Spring’s Environment:
@Bean
CommandLineRunner listSecrets(Environment env) {
return args -> {
var foo = env.getProperty("foo");
Assert.notNull(foo, "foo must have a value");
System.out.println("foo=" + foo);
};
}
When we run this application, we can see the secret’s value in the output, confirming that the integration is working.
7. Transparent Support Using Vault Sidecar
If we don’t want to or can’t change the code of an existing application to get its secrets from Vault, using Vault’s sidecar method is a suitable alternative. The only requirement is that the application is already capable of picking values from environment variables and configuration files.
The sidecar pattern is a common practice in the Kubernetes landscape, where an application delegates some specific functionality to another container running on the same pod. A popular application of this pattern is the Istio service mesh, used to add traffic control policies and service discovery, among other functionalities, to existing applications.
We can use this approach with any Kubernetes workload type, such as a Deployment, Statefulset, or Job. Moreover, we can use a Mutating Webhook to automatically inject a sidecar when a pod is created, thus relieving users from manually adding it to the workload’s specification.
The Vault sidecar uses annotations present in the metadata section of the workload’s pods template to instruct the sidecar which secrets to pull from Vault. Those secrets are then put in a file stored in a shared volume between the sidecar and any other container in the same pod. If any of those secrets is dynamic, the sidecar also takes care of keeping track of its renewal, re-rendering the file when needed.
7.1. Sidecar Injector Deployment
Before we use this method, firstly, we need to deploy Vault’s Sidecar Injector component. The easiest way to do it is to use Hashicorp’s provided helm chart, which, by default, already adds the injector as part of regular Vault deployment on Kubernetes.
If this is not the case, we must upgrade the existing helm release with a new value for the injector.enabled property:
$ helm upgrade vault hashicorp/vault -n vault --set injector.enabled=true
To verify that the injector was properly installed, let’s query the available WebHookConfiguration objects:
$ kubectl get mutatingwebhookconfiguration
NAME WEBHOOKS AGE
vault-agent-injector-cfg 1 16d
7.2. Annotating Deployments
A secret injection is “opt-in”, meaning that no changes will occur unless the injector finds specific annotations as part of a workload’s metadata. This is an example of a deployment manifest using the minimal set of required annotations:
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
namespace: baeldung
spec:
selector:
matchLabels:
app: nginx
replicas: 1
template:
metadata:
labels:
app: nginx
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/agent-inject-secret-baeldung.properties: "secrets/baeldung-test"
vault.hashicorp.com/role: "baeldung-test-role"
vault.hashicorp.com/agent-inject-template-baeldung.properties: |
{{- with secret "secrets/baeldung-test" -}}
{{- range $k, $v := .Data.data }}
{{$k}}={{$v}}
{{- end -}}
{{ end }}
spec:
serviceAccountName: vault-test-sa
automountServiceAccountToken: true
containers:
- name: nginx
image: nginx:1.14.2
ports:
- containerPort: 80
When we deploy this manifest to the cluster, the injector will patch it and inject the Vault agent sidecar container configured as follows:
- Auto-login using baeldung-test-role
- Secrets located under the secrets/baeldung-test path will be renderer to a file named baeldung.properties under the default secret directory (/vault/secrets)
- File content generated using the provided template
There are many more annotations available, which we can use to customize the location and template used to render the secrets. The full list of supported annotations is available on Vault’s documentation.
8. Transparent Support Using Vault Secret CSI Provider
A CSI (Container Storage Interface) provider allows a vendor to extend the types of volumes supported by a Kubernetes cluster. The Vault CSI Provider is an alternative to the use of a sidecar that allows Vault secrets to be exposed to a pod as a regular volume.
The main advantage here is that we don’t have a sidecar attached to each pod, so we need fewer resources (CPU/Memory) to run our workload. While not very resource-hungry, the sidecar cost scales with the number of active pods. In contrast, the CSI uses a DaemonSet, which means there’s one pod for each node in the cluster.
8.1. Enabling the Vault CSI Provider
Before we can install this provider, we must check whether the CSI Secret Store Driver is already present in the target cluster:
$ kubectl get csidrivers
The result should include the secrets-store.csi.k8s.io driver:
NAME ATTACHREQUIRED PODINFOONMOUNT ...
secrets-store.csi.k8s.io false true ...
If this is not the case, it’s just a matter of applying the appropriate helm chart:
$ helm repo add secrets-store-csi-driver https://kubernetes-sigs.github.io/secrets-store-csi-driver/charts
$ helm install csi-secrets-store secrets-store-csi-driver/secrets-store-csi-driver \
--namespace kube-system\
--set syncSecret.enabled=true
The project’s documentation also describes other installation methods, but unless there are some specific requirements, the helm method is the preferred one.
Now, let’s move on to the Vault CSI Provider installation itself. Once again, we’ll use the official Vault helm chart. The CSI provider is not enabled by default, so we need to upgrade it using the csi.enabled property:
$ helm upgrade vault hashicorp/vault -n vault –-set csi.enabled=true
To verify that the driver was correctly installed, we’ll check that its DaemonSet is running fine:
$ kubectl get daemonsets –n vault
NAME DESIRED CURRENT READY UP-TO-DATE AVAILABLE NODE SELECTOR AGE
vault-csi-provider 1 1 1 1 1 <none> 15d
8.2. Vault CSI Provider Usage
Configuring a workload with vault secrets using the Vault CSI Provider requires two steps. Firstly, we define a SecretProviderClass resource that specifies the secret and keys to retrieve:
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
name: baeldung-csi-secrets
namespace: baeldung
spec:
provider: vault
parameters:
roleName: 'baeldung-test-role'
objects: |
- objectName: 'baeldung.properties'
secretPath: "secrets/data/baeldung-test"
Notice the spec.provider property, which must be set to vault. This is required so the CSI Driver knows which of the available providers to use. The parameters section contains information used by the provider to locate the requested secrets:
- roleName: Vault role used during login, which defines the secrets the application will have access to
- objects: The value is a YAML-formatted string (hence the “|”) with an array of secrets to retrieve
Each entry in the objects array is an object with three properties:
- secretPath: Vault’s path for the secret
- objectName: Name of the file that will contain the secret
- objectKey: Key within Vault’s secret that provides the content to put into the file. If omitted, the file will contain a JSON object with all values
Now, let’s use this resource in a sample deployment workload:
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-csi
namespace: baeldung
spec:
selector:
matchLabels:
app: nginx-csi
replicas: 1
template:
metadata:
labels:
app: nginx-csi
spec:
serviceAccountName: vault-test-sa
automountServiceAccountToken: true
containers:
- name: nginx
image: nginx:1.14.2
ports:
- containerPort: 80
volumeMounts:
- name: vault-secrets
mountPath: /vault/secrets
readOnly: true
volumes:
- name: vault-secrets
csi:
driver: 'secrets-store.csi.k8s.io'
readOnly: true
volumeAttributes:
secretProviderClass: baeldung-csi-secrets
In the volumes section, notice how we use a CSI definition pointing to our previously defined SecretStorageClass.
To validate this deployment, we can open a shell into the main container and check the presence of the secret under the specified mount path:
$ kubectl get pods -n baeldung -l app=nginx-csi
NAME READY STATUS RESTARTS AGE
nginx-csi-b7866bc69-njzff 1/1 Running 0 19m
$ kubectl exec -it -n baeldung nginx-csi-b7866bc69-njzff -- /bin/sh
# cat /vault/secrets/baeldung.properties
{"request_id":"eb417a64-b1c4-087d-a5f4-30229f27aba1","lease_id":"","lease_duration":0,
"renewable":false,
"data":{
"data":{"foo":"bar"},
... more data omitted
9. Transparent Support Using Vault Secrets Operator
The Vault Secrets Operator adds Custom Resource Definitions (CRDs) to a Kubernetes cluster, which we can use to populate regular secrets with values pulled from a Vault instance.
Compared to the CSI method, the operator’s main advantage is that we don’t need to change anything on existing workloads to move from standard secrets to Vault-backed ones.
9.1. Vault Secrets Operator Deployment
The operator has its own chart that deploys all required artifacts into the cluster:
$ helm install --create-namespace --namespace vault-secrets-operator \
vault-secrets-operator hashicorp/vault-secrets-operator \
--version 0.1.0
Now, let’s check the new CRDs:
$ kubectl get customresourcedefinitions | grep vault
vaultauths.secrets.hashicorp.com 2023-09-13T01:08:11Z
vaultconnections.secrets.hashicorp.com 2023-09-13T01:08:11Z
vaultdynamicsecrets.secrets.hashicorp.com 2023-09-13T01:08:11Z
vaultpkisecrets.secrets.hashicorp.com 2023-09-13T01:08:11Z
vaultstaticsecrets.secrets.hashicorp.com 2023-09-13T01:08:11Z
As of this writing, the operator defines those CRDs:
- VaultConnection: Defines Vault connection details such as its address, TLS certificates, etc
- VaultAuth: Authentication details used by a specific VaultConnection
- Vault
Secret : Defines a mapping between Kubernetes and Vault secrets, wherecan be Static, Dynamic, or PKI, and corresponds to the secret type.
9.2. Vault Secrets Operator Usage
Let’s walk through a simple example to show how to use this Operator. Firstly, we need to create a VaultConnection resource pointing to our Vault instance:
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultConnection
metadata:
namespace: baeldung
name: vault-local
spec:
address: http://vault.vault.svc.cluster.local:8200
Next, we need a VaultAuth resource with the authentication details we’ll use to access secrets:
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultAuth
metadata:
namespace: baeldung
name: baeldung-test
spec:
vaultConnectionRef: vault-local
method: kubernetes
mount: kubernetes
Kubernetes:
role: baeldung-test-role
serviceAccount: vault-test-sa
Those are the key properties we must fill in:
- spec.vaultConnectionRef: name of the VaultConnection resource we’ve just created
- spec.method: set to kubernetes as we’ll use this authentication method
- spec.kubernetes.role: Vault role to use when authenticating
- spec.kubernetes.serviceAccount: Service account to use when authenticating
Now, let’s define a VaultStaticSecret to map secrets from secrets/baeldung-test on Vault to a secret named baeldung-test:
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
namespace: baeldung
name: baeldung-test
spec:
vaultAuthRef: baeldung-test
mount: secrets
type: kv-v2
path: baeldung-test
refreshAfter: 60s
hmacSecretData: true
destination:
create: true
name: baeldung-test
Finally, we can use kubectl to confirm that the secret was correctly created:
$ kubectl get secret -n baeldung baeldung-test
NAME TYPE DATA AGE
baeldung-test Opaque 3 24h
10. Method Comparison
As we’ve seen, Kubernetes-based applications have no shortage of alternatives to access secrets from Vault. To help choose which one is best suited for a given use case, we’ve put together a short comparison of each method’s features/characteristics:
Feature/Characteristic
Explicit
Semi-Explicit
Injector
CSI
Operator
Requires code changes
Yes
No (deps only)
No
No
No
Access to Vault’s API
Full control
Read only
Partial (e.g., no admin APIs access)
Limited
Limited
Extra resources needed
No
No
Yes, one extra container per pod
Yes, one per node
Yes, one per cluster
Transparent to existing applications
No
No
Partial (requires extra annotations)
Partial (requires extra volumes)
None
Requires cluster changes
No
No
Yes
Yes
Yes
11. Conclusion
In this tutorial, we’ve explored different ways to access secrets stored in a Vault instance from Kubernetes-based applications.
As always, all code is available over on GitHub.