Every database client makes a quiet decision on its first connection: encrypt the transport, or don’t. A lot of drivers decide don’t by default, and a lot of managed databases won’t override them. Put those two defaults together and you get a connection that opens, serves traffic, and passes health checks while carrying your credentials and every query in cleartext.
The trap
The widely used Go MySQL driver go-sql-driver/mysql sends no TLS unless the DSN carries a tls parameter. Managed MySQL often ships with require_secure_transport disabled, so the server happily accepts a plaintext handshake. Nothing in the path objects: the driver requested no encryption, the server allowed it, the socket opened, Ping() returned nil. The only symptom is one you have to go looking for; the session’s negotiated cipher is empty. Run SHOW SESSION STATUS LIKE 'Ssl_cipher', get back a blank value, and you are talking to your database in the clear.
That is the general shape of transport-security bugs: the insecure path is a valid path, so the failure is silent. An omitted parameter is indistinguishable from a correct one until you inspect the negotiated result. “It connected” tells you nothing about how.
Fail closed
The fix is not “remember to set the parameter.” Anything you have to remember gets forgotten in one environment eventually. The fix is to make the process refuse to start without verified TLS everywhere except local development:
// Outside development, refuse to boot unless the DB link is verified TLS.
// Don't trust that the deploy env var was set; enforce it in the binary.
if env != "development" {
switch cfg.TLSMode {
case "verified": // pinned CA + hostname verification
// permitted
case "":
return errors.New("DB_TLS_MODE required: refusing to connect in cleartext")
default:
return fmt.Errorf("DB_TLS_MODE %q not permitted: only verified TLS allowed", cfg.TLSMode)
}
}
Three properties matter. It’s enforced in the binary, not the deploy config, infrastructure-as-code sets the mode, but production safety can’t depend solely on that env var surviving every rollback and copied manifest; the process validates its own config and exits non-zero when the guarantee is missing. It requires verified, not merely encrypted, encryption without authentication stops passive eavesdropping but not an active man-in-the-middle, so skip-verify and “preferred” modes are rejected right alongside plaintext. Development is the only exception, explicit and narrow, because local MySQL usually has no TLS.
Pinning the CA was necessary, not paranoia: the managed provider fronts MySQL with a private, per-project CA, so a naive tls=true fails against the system trust store with x509: certificate signed by unknown authority. Embed the provider’s public CA in the binary, version-controlled, no runtime fetch, and leave the driver’s ServerName empty so it fills from the DSN host and still verifies the hostname.
Prove it, don’t assume it
Configuration is a request; the negotiated session is the fact. After connecting, read the cipher the server actually agreed to, SHOW SESSION STATUS LIKE 'Ssl_version' and Ssl_cipher, and log it at startup: TLS 1.3, cipher TLS_AES_128_GCM_SHA256, or a loud warning when the cipher comes back empty. Now “encrypted” is evidence in a log line, not an assumption in a config file, and a silent downgrade announces itself the next time the service boots.
The lesson generalizes past databases. For any transport-security control, the safe default has to be the enforced default, and you treat “it connected” as insufficient evidence that it connected securely. Read the negotiated result, and fail closed when it isn’t what you required.