Configuring mTLS in production
Mutual TLS (mTLS) is the strongest auth quicSQL offers and the one with zero per-request cost: the client proves its identity with a certificate at the TLS handshake, so there is no shared secret to leak, no token to rotate, and no extra round trip. This guide takes you from “no certificates” to a working, hardened mTLS deployment, with copy-pasteable commands and config. If you only want the concepts, read the auth & authz guide first; this is the hands-on production recipe.
What you are building
mTLS involves four pieces. Keep them straight and the rest is mechanical:
┌─────────────┐ signs ┌────────────────┐ trusts ┌──────────────┐
│ server CA │ ─────────▶ │ server leaf │ ◀─────────────────── │ the client │ (client puts server CA in RootCAs)
└─────────────┘ │ (SANs = the │ │ │
│ hostnames │ presents its cert │ │
│ clients dial)│ ◀─────────────────── │ │
┌─────────────┐ signs └────────────────┘ └──────────────┘
│ client CA │ ─────────▶ client certs ───────────────────────────────▲
└─────────────┘ (CN or key = the principal) │
▲ │
└──────────── the server trusts this CA (client_ca) and maps ────────┘
each verified cert to a principal- A server CA signs the server’s leaf certificate. Clients trust this CA so they can verify they are talking to the real server.
- A server leaf certificate whose SANs list every hostname/IP clients will dial. This is standard TLS — it encrypts the channel and authenticates the server to clients.
- A client CA signs the client certificates. The server trusts this CA (
client_ca) to decide which client certs are genuine. - Client certificates, one per identity. The server maps each verified cert to a principal by its subject Common Name (CN) or by a hash of its public key.
The server CA and client CA can be the same CA, but in production they are usually separate: that way, being able to issue a client cert does not also let you mint a server certificate. This guide uses two CAs.
Step 1 — create the two CAs
Any PKI works (your org’s CA, step-ca, Vault, cfssl). Here it is with plain openssl and ECDSA P-256 keys, which every TLS client accepts:
# Server CA — signs the server leaf that clients verify.
openssl ecparam -name prime256v1 -genkey -noout -out server-ca.key
openssl req -x509 -new -key server-ca.key -sha256 -days 3650 -subj "/CN=quicsql server CA" -out server-ca.crt
# Client CA — signs client certs; the server trusts this to admit clients.
openssl ecparam -name prime256v1 -genkey -noout -out client-ca.key
openssl req -x509 -new -key client-ca.key -sha256 -days 3650 -subj "/CN=quicsql client CA" -out client-ca.crtGuard the two *-ca.key files like root passwords — whoever holds the client CA key can mint an identity the server will trust (subject to a matching principal, see Step 4).
Step 2 — the server leaf certificate
The leaf’s SANs must contain every name or address clients dial, or their TLS verification fails. Include DNS names and any bare IPs:
openssl ecparam -name prime256v1 -genkey -noout -out server.key
openssl req -new -key server.key -subj "/CN=db.example.com" -out server.csr
cat > server.ext <<'EOF'
subjectAltName = DNS:db.example.com, DNS:*.db.internal, IP:203.0.113.10
extendedKeyUsage = serverAuth
EOF
openssl x509 -req -in server.csr -CA server-ca.crt -CAkey server-ca.key -CAcreateserial \
-days 825 -sha256 -extfile server.ext -out server.crtStep 3 — a client certificate per identity
Give each identity its own key and certificate. The simplest mapping is CN = the principal name:
openssl ecparam -name prime256v1 -genkey -noout -out analyst.key
openssl req -new -key analyst.key -subj "/CN=analyst" -out analyst.csr
cat > client.ext <<'EOF'
extendedKeyUsage = clientAuth
EOF
openssl x509 -req -in analyst.csr -CA client-ca.crt -CAkey client-ca.key -CAcreateserial \
-days 90 -sha256 -extfile client.ext -out analyst.crtShort client-cert lifetimes (weeks, not years) are good practice — rotation is your revocation story (see below). If you plan to pin the exact public key instead of the CN, compute its hash now:
openssl x509 -in analyst.crt -pubkey -noout | openssl pkey -pubin -outform DER | openssl dgst -sha256
# → the hex string you put in spki_sha256Step 4 — the quicSQL config
Now wire it together. The tls profile carries the server leaf and the client CA; the listener uses that profile and accepts mtls; each principal declares which certificate identifies it; grants say what each may do.
tls:
prod:
mode: files
cert: /etc/quicsql/tls/server.crt
key: /etc/quicsql/tls/server.key
client_ca: /etc/quicsql/tls/client-ca.crt # the server verifies client certs against THIS
min_version: "1.3" # require TLS 1.3 in production
listeners:
- name: h2
transport: h2
address: 0.0.0.0:7777
tls: prod
auth: [mtls] # sole method → a valid client cert is MANDATORY
auth:
principals:
- name: analyst
methods:
- mtls: { subject_cn: analyst } # map by certificate CN
- name: reporting
methods:
- mtls: { spki_sha256: "3b0c…" } # map by exact public key (pin)
databases:
- name: app
backend: file
path: /var/lib/quicsql/app.db
mode: rwc
grants:
- { principal: analyst, level: read-write }
- { principal: reporting, level: read-only }Two behaviors decided by that auth list are worth understanding precisely:
auth: [mtls](mTLS is the only method) → the listener requires and verifies a client certificate on every connection. No cert, no connection. Use this for a locked-down service port.auth: [mtls, bearer, keyring](mTLS alongside others) → a presented client cert is verified and, if it maps to a principal, authenticates the request; but clients using bearer or keyring may still connect without a cert. Use this when one port serves mixed client types.
Either way, verification and identity are checked independently: a certificate that chains to the trusted client CA but maps to no principal is rejected. The principal table is the real gate.
CN pinning vs public-key (SPKI) pinning
You map a cert to a principal two ways, and the choice affects rotation:
| Map by | Config | Rotation | Trust model |
|---|---|---|---|
| Subject CN | subject_cn: analyst | Reissue a new cert with the same CN (new key is fine) and nothing in the config changes | Trusts your client CA to only issue the CNs you intend |
| Public key hash | spki_sha256: "3b0c…" | Rotating the keypair changes the hash, so you must update the config | Pins the exact key; even the CA cannot mint an accepted cert for a different key |
CN pinning is operationally friendlier (rotate certs without touching quicSQL). SPKI pinning is stricter and useful when you do not fully trust the CA’s issuance process, or for a handful of high-value identities. You can mix both across principals.
The client side (Go)
The client verifies the server against the server CA (WithRootCA) and presents its own client certificate (WithClientCert):
import (
"crypto/tls"
"crypto/x509"
"os"
"quicsql.net/client"
)
caPEM, _ := os.ReadFile("server-ca.crt") // the CA that signed the SERVER leaf
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(caPEM)
cert, _ := tls.LoadX509KeyPair("analyst.crt", "analyst.key") // THIS identity
c := client.H2TLS("db.example.com:7777", false, // false = verify the server (never true in prod)
client.WithRootCA(pool),
client.WithClientCert(cert))
defer c.Close()A DSN cannot carry a certificate and a private key, so the database/sql driver reaches mTLS by building this *client.Client and handing it to sqldriver.OpenConnectorClient(c, "app") — see the Hrana guide and the driver docs.
Testing from the command line
curl speaks mTLS, so you can validate the whole chain without writing code:
curl --cacert server-ca.crt --cert analyst.crt --key analyst.key \
https://db.example.com:7777/app/query \
-H 'content-type: application/json' \
-d '{"sql":"SELECT 1"}'A 401 means the cert verified but maps to no principal (or you omitted it on a mandatory-mTLS port); a TLS handshake error means the server CA or SANs are wrong.
Production practices
- Require TLS 1.3 (
min_version: "1.3"). h3/QUIC listeners are 1.3 by definition. - Separate the client CA from the server CA, so issuing client identities cannot also forge servers.
- Short-lived client certs + rotation. quicSQL verifies the chain and maps the identity; it does not consult CRLs or OCSP. So your revocation levers are, in order of speed: (1) remove the principal (or its
mtlsmethod) from the config and reload — a valid, unexpired cert becomes useless the instant no principal maps it; (2) rotate/renumber the client CA to invalidate everything it signed; (3) let short lifetimes expire. Design around (1): the principal table is your kill switch. - Rotating the server leaf (files mode loads certificates at startup) needs a process restart. Do it as a rolling restart behind a load balancer for zero downtime. Keep the SANs stable so clients don’t need changes.
- Store keys with tight permissions (
0600, owned by the quicSQL user) and outside the repo. Pull them from a secret manager in your deployment rather than baking them into an image. - Never ship the dev credentials. The example modules embed a fixed dev CA and client cert for convenience; replace every one of them.
- Give each identity the least grant it needs, per database. mTLS answers “who,” but the
grantsstill decide “what” — a read-only reporting cert should hold onlyread-only.
Minimal checklist
- Two CAs created; CA keys stored securely.
- Server leaf issued with SANs covering every dialed name/IP.
- One client cert per identity; CN (or pinned SPKI) chosen.
tlsprofile withcert,key,client_ca,min_version: "1.3".- Listener on
h2/h3withauth: [mtls](or mtls plus others). - A principal per cert (
subject_cnorspki_sha256) and a per-database grant for each. - Client uses
WithRootCA(serverCA)+WithClientCert(cert); verified (insecure=false).