Skip to content

TLS and mTLS

With a certificate and its key, Hardcharger™ serves Warp SQL over HTTPS on the same port. Add a CA, and it also requires every client to present a certificate that chains to it (mutual TLS). Clients default to TLS: hardcharger://host and jdbc:hardcharger://host are HTTPS unless you say otherwise.

In production, use a certificate from your CA (or ACME) for the name clients connect to. For this guide, make a throwaway CA and a server certificate for localhost:

Terminal window
# A CA, then a server certificate for localhost signed by it (valid for 30 days).
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -days 30 \
-subj "/CN=Example CA" -keyout ca.key -out ca.crt
openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -subj "/CN=localhost" \
-keyout server.key -out server.csr
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -days 30 \
-extfile <(printf 'subjectAltName=DNS:localhost,IP:127.0.0.1') -out server.crt

server.crt may hold the whole chain (leaf first), as most CAs deliver it.

Terminal window
HARDCHARGER_ADMIN_PASSWORD='change-me' hardcharger-server --database my.duckdb \
--tls-cert server.crt --tls-key server.key --health-port 31338

--tls-cert and --tls-key go together (HARDCHARGER_TLS_CERT, HARDCHARGER_TLS_KEY, or tls_cert/tls_key in YAML). The health port stays plaintext, for probes on a private network.

Clients verify the certificate against the system’s trust store. A private CA has to be named:

from adbc_driver_hardcharger import dbapi as hardcharger
with hardcharger.connect(uri="hardcharger://localhost", username="hardcharger", password="change-me",
tls_root_certs="ca.crt") as conn:
with conn.cursor() as cur:
cur.execute(operation="SELECT 'over TLS' AS hello")
print(cur.fetchone())
('over TLS',)

tls_root_certs takes a file path or PEM text.

With --mtls-ca-cert, the server completes a TLS handshake only with clients that present a certificate signed by that CA. By itself it is an extra lock, not a replacement for signing in: clients still authenticate with a password or token. To make the certificate the credential, turn on certificate sign-in.

  1. Issue a client certificate from your CA. For this guide:

    Terminal window
    openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -subj "/CN=joe" \
    -keyout client.key -out client.csr
    openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -days 30 \
    -extfile <(printf 'extendedKeyUsage=clientAuth') -out client.crt
  2. Restart the server with the CA that client certificates must chain to:

    Terminal window
    HARDCHARGER_ADMIN_PASSWORD='change-me' hardcharger-server --database my.duckdb \
    --tls-cert server.crt --tls-key server.key --mtls-ca-cert ca.crt --health-port 31338
  3. Connect with the client certificate and key:

    from adbc_driver_hardcharger import dbapi as hardcharger
    with hardcharger.connect(uri="hardcharger://localhost", username="hardcharger", password="change-me",
    tls_root_certs="ca.crt", tls_client_cert="client.crt",
    tls_client_key="client.key") as conn:
    with conn.cursor() as cur:
    cur.execute(operation="SELECT 'mutual TLS' AS hello")
    print(cur.fetchone())
    ('mutual TLS',)

A client without a certificate doesn’t get past the handshake, and the CLI says why:

Terminal window
$ curl -s --cacert ca.crt https://localhost:31337/warp || echo "refused"
refused
$ HARDCHARGER_PASSWORD='change-me' hardcharger --host localhost --tls-roots ca.crt --username hardcharger --command "SELECT 1"; echo "exit status $?"
Error: The server at https://localhost:31337 refused the connection: it requires a client certificate (--mtls-cert and --mtls-key)
exit status 1

With --mtls-certificate-sign-in, a client signs in as its certificate’s identity, with no password: Warp SQL’s client_certificate grant, --auth-type mtls in the CLI. The certificate proves who the user is; grants decide what they may do. It is off by default: with --mtls-ca-cert alone, a certificate only admits the connection, as above.

  1. Issue a certificate whose e-mail SAN is the identity (the default identity source):

    Terminal window
    openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -subj "/CN=Ops" \
    -keyout ops.key -out ops.csr
    openssl x509 -req -in ops.csr -CA ca.crt -CAkey ca.key -CAcreateserial -days 30 \
    -extfile <(printf 'extendedKeyUsage=clientAuth\nsubjectAltName=email:ops@example.com') -out ops.crt
  2. Restart the server with certificate sign-in on, and say which identities are administrators:

    Terminal window
    HARDCHARGER_ADMIN_PASSWORD='change-me' hardcharger-server --database my.duckdb \
    --tls-cert server.crt --tls-key server.key --mtls-ca-cert ca.crt \
    --mtls-certificate-sign-in --mtls-admin-identities ops@example.com --health-port 31338
  3. Connect with the certificate and no user name or password:

    from adbc_driver_hardcharger import dbapi as hardcharger
    with hardcharger.connect(uri="hardcharger://localhost", auth_type="client_certificate",
    tls_root_certs="ca.crt", tls_client_cert="ops.crt",
    tls_client_key="ops.key") as conn:
    print(conn.current_user())
    ops@example.com

A certificate whose identity is no administrator and, under access control, no user is refused. client.crt from above has no e-mail SAN at all:

Terminal window
$ curl -s --cacert ca.crt --cert client.crt --key client.key -H 'Content-Type: application/json' \
-d '{"grant_type": "client_certificate"}' https://localhost:31337/warp/v1/auth/token | jq -r .code
unknown_certificate_identity

The access token from a certificate sign-in is bound to that certificate (RFC 8705’s cnf claim): it works only on connections presenting the same one, so a copied token is no use without the private key. Here the token goes out over a connection with another certificate:

Terminal window
$ TOKEN=$(curl -s --cacert ca.crt --cert ops.crt --key ops.key -H 'Content-Type: application/json' \
-d '{"grant_type": "client_certificate"}' https://localhost:31337/warp/v1/auth/token | jq -r .access_token)
$ curl -s --cacert ca.crt --cert client.crt --key client.key -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{}' https://localhost:31337/warp/v1/sessions | jq -r .code
certificate_mismatch

A session opened with a bound token takes only tokens bound to the same certificate, and a scoped token minted over a bound one carries the binding too. The drivers present the certificate on every connection they open (result partitions, ingest parts).

Option Default What it does
--mtls-certificate-sign-in off Offer certificate sign-in at all. Off, or with neither administrator identities nor access control (nobody it could sign in), the grant isn’t listed in discovery and is answered unsupported_grant_type
--mtls-identity-source san_email Where the identity comes from: the e-mail SAN, the URI SAN (san_uri, for SPIFFE IDs) or the subject CN (subject_cn). A certificate with none, or several, values of that kind is refused
--mtls-admin-identities none Identities that sign in as the administrator. Under access control any other identity must be an enabled user (below); without access control, no other identity signs in
--mtls-require-client-cert on --mtls-require-client-cert=false: clients without a certificate may connect and sign in another way
--mtls-crl-file none Revocation lists (below)
--mtls-crl-allow-missing-ca-crls off Accept a CA above the leaf that has no CRL in the file
--mtls-require-identity-match off A password or token sign-in over a client certificate must be for the certificate’s identity (certificate_identity_mismatch otherwise): the certificate becomes a second factor

How identities match user names: an e-mail address or CN ignoring ASCII case, as user names compare; a URI identity such as a SPIFFE ID only exactly, case included (its path is case-sensitive). Identities are printable ASCII; a certificate with anything else is refused.

With access control on, an identity that isn’t in --mtls-admin-identities signs in only as an enabled user of the store with that name. It needs no password; its roles decide what it may do:

CREATE USER "joe@example.com";
GRANT ROLE analyst TO USER "joe@example.com";

A SPIFFE ID is a user name like any other (CREATE USER "spiffe://example.org/ns/etl/sa/loader", always system scope, never a tenant’s). A name in --mtls-admin-identities must not also be a user of the store: certificate sign-in refuses it until one of the two goes.

--mtls-crl-file names a PEM file of CRLs, one for every CA of a client’s chain (the root’s too). Every certificate of the chain is checked, so a revoked intermediate CA revokes what it issued, and a CA without a CRL is refused unless --mtls-crl-allow-missing-ca-crls. Of several CRLs from one CA the newest is used; delta CRLs are refused. Revoke client.crt with this guide’s CA:

Terminal window
printf '[ca]\ndefault_ca = CA_default\n[CA_default]\ndatabase = index.txt\ncrlnumber = crlnumber\ndefault_md = sha256\ndefault_crl_days = 7\n' > ca.cnf
touch index.txt && echo 1000 > crlnumber
openssl ca -config ca.cnf -keyfile ca.key -cert ca.crt -revoke client.crt
openssl ca -config ca.cnf -keyfile ca.key -cert ca.crt -gencrl -out ca.crl
Terminal window
HARDCHARGER_ADMIN_PASSWORD='change-me' hardcharger-server --database my.duckdb \
--tls-cert server.crt --tls-key server.key --mtls-ca-cert ca.crt \
--mtls-certificate-sign-in --mtls-admin-identities ops@example.com \
--mtls-crl-file ca.crl --health-port 31338

The revoked certificate no longer gets past the handshake; the others still do:

Terminal window
$ curl -s --cacert ca.crt --cert client.crt --key client.key https://localhost:31337/warp || echo "refused"
refused
$ curl -s --cacert ca.crt --cert ops.crt --key ops.key https://localhost:31337/warp | jq -r .server.name
Hardcharger
$ hardcharger --host localhost --tls-roots ca.crt --mtls-cert client.crt --mtls-key client.key \
--auth-type mtls --command "SELECT 1"; echo "exit status $?"
Error: The server at https://localhost:31337 refused the client certificate: certificate revoked
exit status 1

The server looks at the file every 5 seconds and reloads it when it changes (a failed reload is retried), so revoking needs no restart. Connections already open are checked again then, and when a CRL passes its nextUpdate: their next request gets 401 certificate_revoked. An expired CRL refuses every certificate its CA issued, so publish a new one before nextUpdate.

Every certificate sign-in, allowed or refused, is an audit record (kind: authentication, method: client_certificate, the identity, subject, issuer, serial and thumbprint), and hardcharger_sessions() shows client_certificate as the session’s auth_method. Refusals (certificate_mismatch, certificate_identity_mismatch, certificate_revoked, unknown_certificate_identity, and certificates the TLS handshake refuses) are security warnings with the user, the token’s id, the certificate presented and the client’s address. They are rate-limited per source: ten a minute in full, then one summary that counts the rest. A connection is closed after five failed sign-ins.

TLS must end at Hardcharger for any of this (a TCP or TLS-passthrough load balancer): a proxy that ends TLS keeps the certificate.

A browser presents the user’s client certificate on a cross-origin request only when the page asks for credentials (credentials: "include", the JavaScript client’s default with authType: "client_certificate"), and lets the page read the answer only if it allows them. An origin in cors_origins gets Access-Control-Allow-Credentials: true with its Access-Control-Allow-Origin, preflights included; other origins get no CORS headers:

Terminal window
$ curl -s -o /dev/null -D - -X OPTIONS localhost:31337/warp/v1/auth/token \
-H 'Origin: https://app.example.com' -H 'Access-Control-Request-Method: POST' \
| grep -i '^access-control-allow-\(origin\|credentials\)' | tr -d '\r'
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true

A preflight carries no certificate, so a server that refuses handshakes without one fails it. For browser pages on another origin, make certificates optional at the handshake (--mtls-require-client-cert=false) and let certificate sign-in do the checking. On the server’s own origin there is no preflight.

If a load balancer terminates TLS, run Hardcharger on plain HTTP on a private network, or re-encrypt to Hardcharger with its own certificate. Client certificates must then be checked by the proxy, since Hardcharger never sees them. See Behind a load balancer.

The server reads its certificate and key at startup. To rotate, replace the files and restart (with several replicas behind a load balancer, one at a time; graceful shutdown lets running statements finish).