Securely Managing Database Secrets With Vault
In this post, I will show how to setup the root and user credentials for a Postgres database in a truly secure way, leveraging a secret management system.
Why You Need A Secret Management System
When I develop a web application, I always need a database to store data. The database setup needs to define a root user with a password. This root user is the database admin, and it must be used to create new users and grant them permission to access the database.
I usually use environment variables to store database credentials. But environment variables are not secure, they can be accessed by any process running on the server. It can also happen that I accidentally commit them to a public repository. If the root password is compromised, the attacker can access the database and steal all the data.
Secret management systems like Vault can create temporary database credentials with specific privileges. Vault is a great tool to securely manage secrets. It can be used to store database credentials, API keys, certificates, and many other secrets. It provides a secure and easy way to access secrets.
Installing and Configuring Vault
For the purpose of this post, I will use the docker images of Vault and PostgreSQL. Here is the docker-compose.yml file I used :
version: "3"
services: vault: image: vault:1.11.0 container_name: vault ports: - "8200:8200" volumes: - ${PWD}/vault:/vault/file/ - ./config.hcl:/vault/config/config.hcl cap_add: - IPC_LOCK command: server
postgres: image: postgres:14 ports: - "5432:5432" environment: - POSTGRES_PASSWORD=passwordI must create a configuration file called config.hcl (hcl extension means HashiCorp Configuration Language) to define how Vault is listening to API requests and where it is storing data. As I am using the docker image, I have to mount this file in /vault/config/ folder of the container. This is where Vault will read the configuration at startup.
Here is the configuration file I used:
listener "tcp" { address = "0.0.0.0:8200" tls_disable = 1}
storage "file" { path = "/vault/file"}In the docker-compose file, I also add the IPC_LOCK capability to allow the Vault process to lock memory. This is a security feature that prevents Vault from swapping data to disk. Indeed, if some data is swapped to a disk that is not encrypted, it can be stolen by an attacker.
I start the containers with docker-compose up -d. Vault is available on port 8200, and PostgreSQL is available on port 5432. The PostgreSQL container is configured with the default user and database postgres and the password password. But don’t worry, I will change this unsafe password in the next steps.
I can access the database from my host with psql -h localhost -p 5432 -U postgres. After entering the password I am connected to the database with root access.
Initializing Vault
I will use the Vault CLI to communicate with Vault, but all the following operations can also be done using the Vault API with a tool like Postman.
First, I need to make sure that the Vault CLI is installed on my system. Then, I have to set the Vault address in my environment variables:
export VAULT_ADDR=http://localhost:8200On the first startup, Vault requires some keys that will serve to encrypt/decrypt the secrets it stores. These keys are called unseal keys. To generate them, I have to run the vault operator init command:
vault operator init -key-shares=5 -key-threshold=3This command generates 5 unseal keys and a root token that will be used to authenticate to Vault with root privileges. The -key-threshold=3 option means that 3 unseal keys are required to unseal the Vault. The output of the command looks like this:
Unseal Key 1: 8FGP4Oa/yhHPcXdt8hLF2owLWta4NfsnlAEkEzH7fW/IUnseal Key 2: yFlzvwYvbCKkF9kpA3okLF5CaijBnurBmsT5RdPKU8kPUnseal Key 3: 06O8NNtMB3872rEw+0r1HdNEM0o7ZaWWQR2+dLNoO1JaUnseal Key 4: v9+uIn0IzTE5FEfTr8IGSWIaQs/pDJkMtDozNkJEe7R9Unseal Key 5: glc1oY4ytrz4ntVoYDlOJjF0ulC13qlNwbEpjhbjBeQ5
Initial Root Token: hvs.CXPrUmR6vZkrjwYNwvRxusaJIt is a good practice to generate more than one unseal key and to set a threshold also greater than one. This way, I can give each key to a different people, and even if one key has been stolen, the attacker will not be able to unseal the Vault. In this example, I have generated 5 unseal keys, and 3 of them are required to unseal the Vault.
When the Vault server starts, it is sealed. It means that it has no access to encrypted data. I must unseal it after each restart with the following command:
vault operator unsealI am then prompted to enter one of the unseal keys. As I set the key-threshold to 3, I have to repeat this command 3 times with 3 different keys to unseal Vault. After each command, the seal status is displayed:
Key Value--- -----Seal Type shamirInitialized trueSealed trueTotal Shares 5Threshold 3Unseal Progress 1/3Unseal Nonce 0c115cc4-ace5-6867-40a4-2817d4e5ea89Version 1.11.0Build Date 2022-06-17T15:48:44ZStorage Type fileHA Enabled falseFor the next step, I must be logged in with the root token provided at the initialization step. I can do so with the vault login command:
vault login hvs.CXPrUmR6vZkrjwYNwvRxusaJI am now logged in with root privileges.
Creating Vault Roles
HachiCorp recommends using root token only for initial setup or emergencies. For all other operations, I must create a role with a policy that allows performing the required operations.
For the next steps, I will need two different roles:
db-adminwill be used to mount the database secret engine, configure it and create database roles.appwill be used by the application to get credentials to access the database.
I create a file db-admin-policy.hcl to describe the policy of the db-admin role:
# Mount database secrets enginespath "sys/mounts/database" { capabilities = [ "create", "update" ]}
# Configure the database secrets engine and create rolespath "database/*" { capabilities = [ "create", "update" ]}Now, I can create the db-admin role with the vault policy write command:
vault policy write db-admin db-admin-policy.hclTo log in with the db-admin role for the next step, I will need a token that I create with the following command:
vault token create -policy=db-adminThe output of the command looks like this:
Key Value--- -----token hvs.CAESIEGTmPiq0QwsDKlZifZMp9uudQt5KeeTH5NO2OoWRtBAGh4KHGh2cy5zUkRkckNnZWdhT1NwcVZWUzJPN1RtUGQtoken_accessor jTdfOmPgcAZDjSWXPP9UGd4Ltoken_duration 768htoken_renewable truetoken_policies ["db-admin" "default"]identity_policies []policies ["db-admin" "default"]I follow the same steps to create the app role with an app-policy.hcl file which describes the policy of the app role:
# Read database credentialspath "database/creds/db-app" { capabilities = [ "read" ]}db-app in the above file is the name of the database role that I will create in the next step. My app Vault role will only be able to obtain database credentials with privileges defined in the db-app role.
Managing Database Root Credentials
For this step, I will use the db-admin role. I must log in with the token created in the previous step:
vault login hvs.CAESIEGTmPiq0QwsDKlZifZMp9uudQt5KeeTH5NO2OoWRtBAGh4KHGh2cy5zUkRkckNnZWdhT1NwcVZWUzJPN1RtUGQVault provides a secrets engine to manage database credentials. I enable it with the following command:
vault secrets enable databaseNow, I have to configure this database secrets engine with the vault write command. To do that, I must provide the database root connection information. As I am using a PostgreSQL database, I use the postgresql-database-plugin Vault plugin :
vault write database/config/my-postgres-database \ plugin_name=postgresql-database-plugin \ connection_url="postgresql://{{username}}:{{password}}@postgres:5432/postgres" \ allowed_roles="*" \ username="postgres" \ password="password"my-postgres-database is an arbitrary name that I give to the database secrets engine configuration. username and password are the current database root credentials defined in the docker-compose file.
Now, Vault has root access to the database. Remember, I said earlier that I must change the password password as soon as possible. It is now time to do it. I rotate the root credentials with the following command:
vault write -force database/rotate-root/my-postgres-databaseTo check it, I try to access again the database from my host with psql -h localhost -p 5432 -U postgres. After entering the password password, I see that the authentication fails. So now, only Vault knows the new root password.
If only Vault has access to the database, how can my application access it? In the next step, I will set up a database role that will be used by Vault to create a new database user when my application will ask Vault for database credentials.
Creating Database Role For Our Application
I will create the db-app database role that will only have the SELECT privilege on all tables in the public schema.
First, I create a readonly.sql file containing the SQL statements to create this role:
CREATE ROLE "{{name}}" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';GRANT SELECT ON ALL TABLES IN SCHEMA public TO "{{name}}";The values of the {{name}}, {{password}} and {{expiration}} placeholders will be provided by Vault when it creates the database user.
Then, I create the db-app database role with the following command:
vault write database/roles/db-app \ db_name=my-postgres-database \ creation_statements=@readonly.sql \ default_ttl=1h \ max_ttl=24hEach time my application asks Vault for database credentials, Vault will create a new user with the creation_statements. This user will be valid for 1 hour and can be renewed for a maximum of 24 hours.
Getting Database Credentials
To get database credentials, our application has to send a GET request to the http://localhost:8200/v1/database/creds/db-app endpoint with the token of the app role in the X-Vault-Token header.
The response body to this request will look like this:
{ "request_id": "f4b4e3e2-af46-dace-5ca6-b4791e479068", "lease_id": "database/creds/db-app/aJIeIYbTPe8IN5BjPSeWCrp1", "renewable": true, "lease_duration": 3600, "data": { "password": "1IsVCSk1JwYex-KtpdyU", "username": "v-token-db-app-0dBW8SFZLpPDbyWxWUdi-1671800140" }, "wrap_info": null, "warnings": null, "auth": null}I can use the username and password to access the database from my host with psql -h localhost -p 5432 -d postgres -U v-token-db-app-0dBW8SFZLpPDbyWxWUdi-1671800140. After entering the password, I see that password authentication succeeds and I am only authorized to execute SELECT queries as I defined in the creation_statements of the db-app role.
I now have to configure my application to use Vault to get database credentials. In my case, I am using an API built with the NestJS framework. I create a getDatabaseConfig function that will send a request to Vault to get database credentials. This function returns a configuration object that will be used to connect to the database:
import "dotenv/config";import { DataSourceOptions } from "typeorm";import { join } from "path";import { HttpService } from "@nestjs/axios";import { firstValueFrom } from "rxjs";
const getDatabaseConfig = async (): Promise<DataSourceOptions> => { const httpService = new HttpService(); const res = await firstValueFrom( httpService.get(`${process.env.VAULT_ADDR}/v1/database/creds/api`, { headers: { "X-Vault-Token": process.env.VAULT_TOKEN, }, }), ); const { username, password } = res.data.data; return { type: "postgres", host: process.env.DATABASE_HOST, port: parseInt(process.env.DATABASE_PORT, 10), username, password, database: process.env.DATABASE_NAME, entities: [join(__dirname, "..", "**", "*.entity.{js,ts}")], migrations: [join(__dirname, "..", "migrations", "*.{js,ts}")], synchronize: false, migrationsRun: false, };};
export default getDatabaseConfig;This getDatabaseConfig function is called in the app.module.ts file of my application:
import { Module } from "@nestjs/common";import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";import getDatabaseConfig from "./database/databaseConfig";
@Module({ imports: [ TypeOrmModule.forRootAsync({ useFactory: async (): Promise<TypeOrmModuleOptions> => { return getDatabaseConfig(); }, }), ],})export class AppModule {}Now, when my application starts, it sends a request to Vault to get database credentials and use them to connect to the database.
Conclusion
In this article, I have shown how to use Vault to securely manage database credentials. Once Vault is configured, its usage is very simple. The only thing to do is to send a request to Vault to get database credentials and use them to connect to the database.
Here, I configured it just to manage database credentials but it could be great to manage all secrets of a project (API keys, passwords, certificates, SSH keys, …) in order to completely replace .env files.
Authors
Full-stack web developer at marmelab, Thibault also manages a local currency called "Le Florain", used by dozens of French shops around Nancy to encourage local exchanges.