Improving Spring Boot Configuration Security with Hashicorp Vault

Published on

The problem

If you’re using Spring Boot with Spring Data JPA, you’ve probably got a configuration file like this sitting near your server:

# application.yml
spring:
  datasource:
    url: jdbc:mysql://mysqldb:3306/mydb
    username: root        # <-- credentials
    password: password123 # <--

If you’re a fan of 12-factor apps, you might draw the app configuration/database credentials from environment variables. Alternately, you might be drawing credentials from your cloud vendor’s secrets manager. In any case, you’re probably using credentials that need to be accessible when the server starts and valid until the server shuts down.

There’s a better way.

Vault can help

Hashicorp Vault supports provisioning short-lived, automatically rotated credentials called “dynamic secrets”. Compared to long-lived credentials, dynamic secrets have a tiny blast radius if they get accidentally invalidated or leaked: each dynamic secret affects a single server for a short period of time. Managing dynamic secrets through Vault’s lease API saves a significant amount of operational pain.

How to use Vault with Spring Boot

tl;dr: use spring-cloud-vault to replace long-lived datasource credentials with dynamic secrets from Vault.

Suppose you have an existing Spring Boot application that talks to a MySQL database.

First, set up a Vault instance for local development using docker compose:

compose.yaml
--- a/compose.yaml
+++ b/compose.yaml
@@ -11,6 +11,33 @@ services: # listed roughly by boot order
+  vault:
+    image: docker.io/hashicorp/vault:latest
+    cap_add: [IPC_LOCK]
+    # ^ see https://man7.org/linux/man-pages/man7/capabilities.7.html#:~:text=the%20calling%20process.-,cap_ipc_lock,-%E2%80%A2%20%20Lock%20memory%20(mlock
+    ports: ["8200:8200"]
+    environment:
+      - VAULT_DEV_ROOT_TOKEN_ID=my-vault-root-token
+      - VAULT_DEV_LISTEN_ADDRESS=0.0.0.0:8200
+    command: server -dev
+    healthcheck:
+      test: [CMD, vault, status, "-address=http://0.0.0.0:8200"]
+      interval: 2s
+      timeout: 30s
+      retries: 10
+      start_period: 1s
   app:
     build: .
     ports: [ "8080:8080" ]

Once Vault is up and running, add a script to configure the MySQL secrets engine in Vault using Vault’s built-in database credential management plugins:

scripts/vault-init.sh
#!/bin/sh
set -u # fail on any reference to an undefined variable
set -e # fail on any unhandled nonzero return code

# see https://developer.hashicorp.com/vault/docs/secrets/databases/mysql-maria
vault secrets enable database

DB_NAME=my-mysql-database
# ^ this only affects the path of the database configuration in Vault;
# it doesn't have to match the database name
ROLE_NAME="my_role"

vault write database/config/$DB_NAME \
    plugin_name=mysql-database-plugin \
    connection_url="{{username}}:{{password}}@tcp(mysql:3306)/" \
    allowed_roles=$ROLE_NAME \
    username="root" \
    password="$MYSQL_ROOT_PASSWORD"

vault write database/roles/$ROLE_NAME \
    db_name=$DB_NAME \
    creation_statements="
      CREATE USER '{{name}}'@'%' IDENTIFIED BY '{{password}}';
      GRANT ALL ON *.* TO '{{name}}'@'%';
    " \
    default_ttl="30s" # short to demonstrate lease renewal

Note that this Vault database role has no max_ttl so that spring-cloud-vault can refresh its dynamic database credentials indefinitely.

You can use docker-compose to ensure run this script runs before your server boots:

compose.yaml
--- a/compose.yaml
+++ b/compose.yaml
@@ -11,6 +11,33 @@ vault:
      timeout: 30s
      retries: 10
      start_period: 1s
+  vault-init:
+    image: docker.io/hashicorp/vault:latest
+    depends_on: 
+      vault: {condition: service_healthy}
+      mysql: {condition: service_healthy}
+    environment:
+      - MYSQL_ROOT_PASSWORD=my_password
+      - VAULT_TOKEN=my-vault-root-token # for demo only
+      - VAULT_ADDR=http://vault:8200
+    volumes:
+      - ./scripts/vault-init.sh:/vault-init.sh
+    command: sh /vault-init.sh
   app:
     build: .
     ports: [ "8080:8080" ]
       mysql: {condition: service_healthy}
+      vault: {condition: service_healthy}
+      vault-init: {condition: service_completed_successfully}
     volumes:

Next, add spring-cloud-vault to your dependencies:

pom.xml
--- a/pom.xml
+++ b/pom.xml
@@ -51,6 +51,21 @@
       <artifactId>spring-boot-starter-test</artifactId>
       <scope>test</scope>
     </dependency>
+    <dependency>
+      <groupId>org.springframework.cloud</groupId>
+      <artifactId>spring-cloud-starter-vault-config</artifactId>
+      <version>4.2.0</version>
+    </dependency>
+    <dependency>
+      <groupId>org.springframework.vault</groupId>
+      <artifactId>spring-vault-core</artifactId>
+      <version>3.1.1</version>
+    </dependency>
+    <dependency>
+      <groupId>org.springframework.cloud</groupId>
+      <artifactId>spring-cloud-vault-config-databases</artifactId>
+      <version>4.2.0</version>
+    </dependency>
     <dependency>
       <groupId>com.mysql</groupId>
       <artifactId>mysql-connector-j</artifactId>

Finally, we can replace the long-lived credentials in our application.yml with dynamic secrets:

config/application.yaml
--- a/config/application.yaml
+++ b/config/application.yaml
@@ -6,5 +6,27 @@ spring:
       # when the server first boots.
   datasource:
     url: jdbc:mysql://mysql:3306/my_db
-    username: root
-    password: my_password
+  cloud:
+    vault:
+      token: ${VAULT_TOKEN}
+      fail-fast: true # interrupt boot if Spring can't connect to Vault
+      uri: ${SPRING_CLOUD_VAULT_URI:http://vault:8200}
+      database:
+        # see https://cloud.spring.io/spring-cloud-vault/reference/html/#vault.config.backends.database
+        enabled: true
+        role: my_role # this is the name of the database role **in vault**. SHOULD correspond to database/roles/my-role
+        backend: database
+        username-property: "spring.datasource.username"
+        password-property: "spring.datasource.password"
+  config:
+    import: "vault://"

Arguably, the most important line property here is spring.config.import=vault://. That line mounts Vault as a PropertySource that uses the backends configured under spring.config.vault (for more information, see the spring cloud vault docs). If you configure everything else correctly but don’t include this property, you’ll use the wrong username/password for the datasource!

This means our app service needs to boot after Vault is online and configured. Also, in this demo, the Spring Boot app needs a $VAULT_TOKEN to authenticate:

compose.yaml
--- a/compose.yaml
+++ b/compose.yaml
@@ -43,9 +42,11 @@ services: # listed roughly by boot order
   app:
     build: .
     ports: [ "8080:8080" ]
     depends_on: 
       mysql: {condition: service_healthy}
       vault: {condition: service_healthy}
       vault-init: {condition: service_completed_successfully}
+    environment:
+      - VAULT_TOKEN=my-vault-root-token # for demo only
     volumes:
       - ./config/application.yaml:/opt/app/config/application.yaml:ro
       # mount an external config file in a location that Spring Boot will check
       # see https://docs.spring.io/spring-boot/reference/features/external-config.html#features.external-config.files

Note that this example app uses Vault’s root token to authenticate to Vault for demonstration purposes only. In production you should authenticate to Vault using a trusted identity from AWS, Azure, Kubernetes, or one of Vault’s other authentication methods.

Now if you start your app, it should successfully initialize:

docker compose up -d --build app
docker compose logs app
output
  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/

 :: Spring Boot ::                (v3.4.3)

YYYY-MM-DDThh:mm:ss.000Z  INFO 1 --- [demo] [           main] c.r.demo.DemoApplication                 : Starting DemoApplication v0.0.1-SNAPSHOT using Java 21.0.2 with PID 1 (/opt/app/app.jar started by root in /opt/app)
YYYY-MM-DDThh:mm:ss.001Z  INFO 1 --- [demo] [           main] c.r.demo.DemoApplication                 : No active profile set, falling back to 1 default profile: "default"
YYYY-MM-DDThh:mm:ss.002Z  INFO 1 --- [demo] [           main] o.s.v.c.e.LeaseAwareVaultPropertySource  : Vault location [secret/demo] not resolvable: Not found
...
YYYY-MM-DDThh:mm:ss.015Z  INFO 1 --- [demo] [           main] o.s.o.j.p.SpringPersistenceUnitInfo      : No LoadTimeWeaver setup: ignoring JPA class transformer
YYYY-MM-DDThh:mm:ss.016Z  INFO 1 --- [demo] [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
YYYY-MM-DDThh:mm:ss.017Z  INFO 1 --- [demo] [           main] com.zaxxer.hikari.pool.HikariPool        : HikariPool-1 - Added connection com.mysql.cj.jdbc.ConnectionImpl@8afce3
YYYY-MM-DDThh:mm:ss.018Z  INFO 1 --- [demo] [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
YYYY-MM-DDThh:mm:ss.019Z  INFO 1 --- [demo] [           main] org.hibernate.orm.connections.pooling    : HHH10001005: Database info:
	Database JDBC URL [Connecting through datasource 'HikariDataSource (HikariPool-1)']
...

and performing CRUD operations should result in data landing in your database

tada 🪄
mysql() {
  docker compose exec mysql \
    mysql --user=root --password=my_password my_db \
    -e "$1"
}

mysql 'select count(*) from kvpair;' | sed 's/^/# /g'
# count(*)
# 0

# make an API call to create some data
curl -X POST http://localhost:8080/kv/foo -d value=bar # => bar

# then the data should appear in the database
mysql 'select * from kvpair;' | sed 's/^/# /g'
# k    v
# foo  bar

Note that no additional configuration is required to renew credentials once the TTL is up: spring-cloud-vault renews credential leases automatically.