Secure Connections to PostgreSQL

Securing PostgreSQL using mutual authentication over TLS commonly known as mTLS using a Self Signed Certificate Authority with good enough encryption.

The importance of implementing encryption for data in transit is a basic requirement for GDPR & HIPAA. Through maintaining good security hygiene even if you're not interested in compliance, you're reducing the chances of data being stolen. Rolling out a Certificate Authority is not an easy task. There are multiple levels to consider. For the purposes of this article, I'll show how to configure a Self Signed Root CA. You could take in further and install the Root CA and Intermediate CAs into Linux or Windows

CA Architecture

Certificate Authority architecture is not as complicated as it might sound. There are however significant security considerations to consider when deciding how services will access adjacent or adjoining services. mTLS between services provides two protections, first by enforcing TLS 1.2/1.3 it reduces the chances of information being leaked. The second the server will authenticate the client when it connects and verify the connecting cert is valid relative to the Root CA. Those two protections are enough to provide good enough security for data in transit. Host Name Verification can be used to further increase security between services; more on that later when talking about Common Name and Subject Alt name.

CA Architecture has a Root CA protected on a machine only accessible to the DevOps utilities requesting and distributing certificates, a machine only a Developer can access manually, or inside a Cloud HSM service. There is zero necessity to have the RootCA directly exposed to the internet. If however that is the case.. you should strongly consider removing the RootCA from anything that can be accessed through a public IPAddress

2 Tier CA Architecture

2 Tier Architecture consists of a RootCA and many service Certificates. In the example below, PostgreSQL, Valkey, and Rocket.rs

CA Architecture

OpenSSL provides a command line interface that'll allow us to create the RootCA and services. Let's walk through an example for PostgreSQL

$ mkdir ca && cd ca
$ openssl genrsa -out ca.key 2048
$ openssl req -new -x509 -key ca.key -out ca.crt -subj=/C=US/ST=None/L=None/O=Dev/CN=root-ca
$ openssl genrsa -out postgresql.key 2048
$ openssl rsa -in postgresql.key -pubout -out postgresql.pub
$ openssl req -new -key postgresql.key -out postgresql.csr -subj=/C=US/ST=None/L=None/O=Dev/CN=postgresql
$ openssl x509 -req -in postgresql.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out postgresql.crt

3 Tier CA Architecture

3 Tier CA Architecture takes it a step further and allows the creation of Key/Cert pairs for Tenants. Anything signed with Tenant A Intermediate CA can only access services from Tenant A. Same for Tenant B, only PostgreSQL B and Valkey B can access each other. Access from PostgreSQL A to PostgreSQL B and vice versa would not work because the connecting Key/Cert pair connecting from either service to the other, would not be signed by an adjacent Intermediate CA

Similar to a 2 Tier CA Architecture, the 3 Tier CA Architecture keeps the Root CA offline and should only be accessed by orchestration software or a developer when necessary

CA Architecture

The process for creating an Intermediate CA is similar to creating Service Key/Cert pairs. However, there are specific parameters available in x509, one being the restriction of additional CA creation. We'll cover those values in a follow up article. For new, lets move forward with generating two tenants, each with a PostgreSQL Key/Cert pair

$ mkdir ca && cd ca
# Root CA
$ openssl genrsa -out ca.key 2048
$ openssl req -new -x509 -key ca.key -out ca.crt -subj=/C=US/ST=None/L=None/O=Dev/CN=root-ca
# Tenant A
$ openssl genrsa -out tenant-a.key 2048
$ openssl rsa -in tenant-a.key -pubout -out tenant-a.pub
$ openssl req -new -key tenant-a.key -out tenant-a.csr -subj=/C=US/ST=None/L=None/O=Dev/CN=tenant-a
$ openssl x509 -req -in tenant-a.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out tenant-a.crt
# PostgreSQL A
$ openssl genrsa -out postgresql-a.key 2048
$ openssl rsa -in postgresql-a.key -pubout -out postgresql-a.pub
$ openssl req -new -key postgresql-a.key -out postgresql-a.csr -subj=/C=US/ST=None/L=None/O=Dev/CN=postgresql-a
$ openssl x509 -req -in postgresql-a.csr -CA tenant-a.crt -CAkey tenant-a.key -CAcreateserial -out postgresql-a.crt
# Tenant B
$ openssl genrsa -out tenant-b.key 2048
$ openssl rsa -in tenant-b.key -pubout -out tenant-b.pub
$ openssl req -new -key tenant-b.key -out tenant-b.csr -subj=/C=US/ST=None/L=None/O=Dev/CN=tenant-b
$ openssl x509 -req -in tenant-b.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out tenant-b.crt
# PostgreSQL B
$ openssl genrsa -out postgresql-b.key 2048
$ openssl rsa -in postgresql-b.key -pubout -out postgresql-b.pub
$ openssl req -new -key postgresql-b.key -out postgresql-b.csr -subj=/C=US/ST=None/L=None/O=Dev/CN=postgresql-b
$ openssl x509 -req -in postgresql-b.csr -CA tenant-b.crt -CAkey tenant-b.key -CAcreateserial -out postgresql-b.crt

Setup PostgreSQL using Docker

For the rest of the article, I'll stick to using a 2 Tier CA Architecture. Let's go ahead and configure PostgreSQL

Configuration

docker-compose.yml

Adding storage as a Docker Service in docker-compose.yml provides a bare minimum configuration for a relatively secure PostgreSQL service running locally. The configuration could easily be expanded and deployed using other orchestration software such as GitOps or k8s. The Docker Image used is postgres:17.2-alpine3.21 which is an official image managed and maintained by the PostgreSQL Docker Community

Using the latest tag for an image is something you could do, but I recommend pinning to a specific version(:17.2-alpine3.21) so when it comes time to update the software, it doesn't migrate the database from a previous version(16) to the latest version(17) without you realizing it. Automation is great, until it fails and you have to figure out what happened

command in docker compose can override CMD with options or arguments. This is possible because the 17.2-alpine3.21 image uses CMD to specify a default command. >- is the Block Chomping Indicator. It flags a yaml parser to remove newlines from the multi-line string. You'll need to coordinate the filepath location relative to where the RootCA and Key/Cert pair is mounted into the container using a few volume entries in the yaml. Finally, set default environment variables for the username, database name, and password to access the container while its in operation and expose the container port 5432 to 8432 or something more to your liking

storage:
  restart: always
  image: postgres:17.2-alpine3.21
  command: >-
    -c ssl=on
    -c ssl_min_protocol_version=TLSv1.3
    -c ssl_cert_file=/var/lib/postgresql/postgresql.crt
    -c ssl_key_file=/var/lib/postgresql/postgresql.key
    -c ssl_ca_file=/var/lib/postgresql/ca.crt
    -c hba_file=/var/lib/postgresql/pg_hba.conf
  environment:
    POSTGRES_DB: dbname
    POSTGRES_USER: dbuser
    POSTGRES_PASSWORD: password
  ports:
    - 8432:5432
  volumes:
    - ./docker-data/postgresql-data:/var/lib/postgresql/data:rw
    - ./docker-data/configs/pg_hba.conf:/var/lib/postgresql/pg_hba.conf:ro
    - ./docker-data/postgresql-logs:/var/log/postgresql:rw
    - ./docker-data/ca/ca.crt:/var/lib/postgresql/ca.crt:ro
    - ./docker-data/ca/postgresql.crt:/var/lib/postgresql/postgresql.crt:ro
    - ./docker-data/ca/postgresql.key:/var/lib/postgresql/postgresql.key:ro

pg_hba.conf

pg_hba.conf file provides the configuration entry point to secure connections being made to various databases for all or for each user relative to how it connects. For our purposes, we'll want something that is secure enough by asking for two challenges. First set hostssl and require a Key/Cert pair signed by the RootCA created above. The second challenge is enabled by specifying scram-sha-256 instead of trust, which will prompt the connecting clients for a password

hostssl dbname  dbuser  127.0.0.1/32    scram-sha-256
hostssl dbname  dbuser  ::1/128         scram-sha-256
hostssl dbname  dbuser  all             scram-sha-256
# Replication Privilges
local   replication     all                                     trust
host    replication     all             127.0.0.1/32            trust
host    replication     all             ::1/128                 trust

The more details for the hb_conf.conf file are available in PostgreSQL source control. By restricting connections to the one database managed by Docker Compose, attackers will need to guess the exact database name to connect into rather than dropping into a default database. We further reduce the attack surface even more by only allowing dbuser to connect

Connection Testing

$ export PGSSLCERT=$PWD/docker-data/ca/postgresql.crt
$ export PGSSLKEY=$PWD/docker-data/ca/postgresql.key
$ export PGSSLROOTCERT=$PWD/docker-data/ca/ca.crt
$ export PGSSLMODE=verify-ca
$ export PGPASSWORD=password
$ psql -h localhost -p 8432 -U dbuser dbname

OpenSSL Debugging

OpenSSL ships with a number of utilities that will help you test your TLS implementation. TLS 1.3 provides a specification that both OpenSSL and rustls implement in order to provide secure connections for transmitting data between services. The specification is also implemented by other languages; I only make a specific note here about OpenSSL and rustls because we'll be using OpenSSL to test rustls in other articles

openssl s_client

s_client will connect to a host using DNS, IPv4, or IPv6 with a Port. Let's use Docker Service storage to test it out

$ openssl s_client -connect localhost:8432 > debug.log 2>&1

Let's take a look at the diagnostic information

$ cat debug.log

CONNECTED(00000005)
---
Certificate chain
 0 s:C=US, ST=None, L=None, O=Dev, CN=postgresql
   i:C=US, ST=None, L=None, O=Dev, CN=rootca
   a:PKEY: rsaEncryption, 2048 (bit); sigalg: RSA-SHA256
   v:NotBefore: Jan  4 09:00:33 2025 GMT; NotAfter: Feb  3 09:00:33 2025 GMT
 1 s:C=US, ST=None, L=None, O=Dev, CN=rootca
   i:C=US, ST=None, L=None, O=Dev, CN=rootca
   a:PKEY: rsaEncryption, 2048 (bit); sigalg: RSA-SHA256
   v:NotBefore: Jan  4 09:00:33 2025 GMT; NotAfter: Feb  3 09:00:33 2025 GMT
---
Server certificate
-----BEGIN CERTIFICATE-----
<redacted>
-----END CERTIFICATE-----
subject=C=US, ST=None, L=None, O=Dev, CN=postgresql
issuer=C=US, ST=None, L=None, O=Dev, CN=rootca
---
Acceptable client certificate CA names
C=US, ST=None, L=None, O=Dev, CN=rootca
Requested Signature Algorithms: ECDSA+SHA256:ECDSA+SHA384:ECDSA+SHA512:Ed25519:Ed448:ECDSA+SHA256:ECDSA+SHA384:ECDSA+SHA512:RSA-PSS+SHA256:RSA-PSS+SHA384:RSA-PSS+SHA512:RSA-PSS+SHA256:RSA-PSS+SHA384:RSA-PSS+SHA512:RSA+SHA256:RSA+SHA384:RSA+SHA512:ECDSA+SHA224:RSA+SHA224
Shared Requested Signature Algorithms: ECDSA+SHA256:ECDSA+SHA384:ECDSA+SHA512:Ed25519:Ed448:ECDSA+SHA256:ECDSA+SHA384:ECDSA+SHA512:RSA-PSS+SHA256:RSA-PSS+SHA384:RSA-PSS+SHA512:RSA-PSS+SHA256:RSA-PSS+SHA384:RSA-PSS+SHA512:RSA+SHA256:RSA+SHA384:RSA+SHA512
Peer signing digest: SHA256
Peer signature type: RSA-PSS
Server Temp Key: ECDH, prime256v1, 256 bits
---
SSL handshake has read 2609 bytes and written 747 bytes
Verification error: self-signed certificate in certificate chain
---
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Protocol: TLSv1.3
Server public key is 2048 bit
This TLS version forbids renegotiation.
Compression: NONE
Expansion: NONE
No ALPN negotiated
Early data was not sent
Verify return code: 19 (self-signed certificate in certificate chain)
---
closed

The diagnostic information provides the full cert chain and verifies the TLS configuration is correct enough for us. What I mean by correct enough, is that we're still using a self-signed cert. That part of the validation will fail, and the part that checks the hostname should fail, but everything else should be fine

Verification error: self-signed certificate in certificate chain
Verify return code: 19 (self-signed certificate in certificate chain)
openssl x509

x509 provides diagnostic information about crt and other files. It's best used when you'd like to learn more about the service your connecting to, or extract more parameters about the CA that signed the certificate

$ openssl x509 -noout -text -in docker-data/ca/ca.crt > debug.log

-noout and -text together seem odd together, at least to me. Together, the options indicate to x509 there will be no output such as a crt or csr file and to print diagnostic information about the certification from the -in option to stdout

Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            <redacted>
        Signature Algorithm: sha256WithRSAEncryption
        Issuer: C=US, ST=None, L=None, O=Dev, CN=rootca
        Validity
            Not Before: Jan  4 09:00:33 2025 GMT
            Not After : Feb  3 09:00:33 2025 GMT
        Subject: C=US, ST=None, L=None, O=Dev, CN=rootca
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                Public-Key: (2048 bit)
                Modulus:
                    <redacted>
                Exponent: <redacted>
        X509v3 extensions:
            X509v3 Subject Key Identifier: 
                <redacted>
            X509v3 Authority Key Identifier: 
                <redacted>
            X509v3 Basic Constraints: critical
                CA:TRUE
    Signature Algorithm: sha256WithRSAEncryption
    Signature Value:
        <redacted>

There is quite a bit of information to look at, most notably is Validity and information on how the fingerprints and block ciphers are implemented for the Certificate. I've <redacted> anything that makes the output harder to read

Common Name(CN) and Subject Alt Name(SAN) extensions provided can be used to verify the hostname against the Server's Certificate to line up with DNS entry or an IPAddress. Traditionally, only Common Name would be used to line up to something like localhost to your server. Subject Alt Name provides the ability to set multiple aliases such localhost, 127.0.0.1, or even pc-name.local. Therefore you'll be able to have as many DNS or IP entries as you'd like