Securely Managing Database Secrets With Vault

Thibault Barrat
Thibault BarratJanuary 05, 2023
#security#tutorial

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=password

I 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:8200

On 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=3

This 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/I
Unseal Key 2: yFlzvwYvbCKkF9kpA3okLF5CaijBnurBmsT5RdPKU8kP
Unseal Key 3: 06O8NNtMB3872rEw+0r1HdNEM0o7ZaWWQR2+dLNoO1Ja
Unseal Key 4: v9+uIn0IzTE5FEfTr8IGSWIaQs/pDJkMtDozNkJEe7R9
Unseal Key 5: glc1oY4ytrz4ntVoYDlOJjF0ulC13qlNwbEpjhbjBeQ5

Initial Root Token: hvs.CXPrUmR6vZkrjwYNwvRxusaJ

It 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 unseal

I 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          shamir
Initialized        true
Sealed             true
Total Shares       5
Threshold          3
Unseal Progress    1/3
Unseal Nonce       0c115cc4-ace5-6867-40a4-2817d4e5ea89
Version            1.11.0
Build Date         2022-06-17T15:48:44Z
Storage Type       file
HA Enabled         false

For 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.CXPrUmR6vZkrjwYNwvRxusaJ

I 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-admin will be used to mount the database secret engine, configure it and create database roles.
  • app will 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 engines
path "sys/mounts/database" {
  capabilities = [ "create", "update" ]
}

# Configure the database secrets engine and create roles
path "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.hcl

To 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-admin

The output of the command looks like this:

Key                  Value
---                  -----
token                hvs.CAESIEGTmPiq0QwsDKlZifZMp9uudQt5KeeTH5NO2OoWRtBAGh4KHGh2cy5zUkRkckNnZWdhT1NwcVZWUzJPN1RtUGQ
token_accessor       jTdfOmPgcAZDjSWXPP9UGd4L
token_duration       768h
token_renewable      true
token_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 credentials
path "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.CAESIEGTmPiq0QwsDKlZifZMp9uudQt5KeeTH5NO2OoWRtBAGh4KHGh2cy5zUkRkckNnZWdhT1NwcVZWUzJPN1RtUGQ

Vault provides a secrets engine to manage database credentials. I enable it with the following command:

vault secrets enable database

Now, 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-database

To 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=24h

Each 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.

Did you like this article? Share it!