{} The Go Reference

Cryptography · Security · Intermediate

TLS & PKI

How strangers establish trust on the internet — the TLS handshake, certificates and the chain of trust, certificate transparency, and mutual TLS — with runnable Go cert generation and verification.

Cryptography Intermediate ⏱ 5 min read Complete

🪪 Analogy

Imagine meeting a stranger who claims to be your bank. Encryption is whispering so eavesdroppers can’t hear — but you’re still whispering to a stranger who might be an impostor. A certificate is their government-issued ID, and the Certificate Authority is the government whose signature you already trust. TLS checks the ID against a trusted issuer before sharing secrets. Encryption keeps the conversation private; the certificate makes sure it’s the right person.

What TLS actually does

TLS solves two problems at once: confidentiality (encryption) and authenticity (you’re really talking to example.com). The handshake is a small dance that combines asymmetric and symmetric crypto:

sequenceDiagram
participant C as Client
participant S as Server
C->>S: ClientHello (ciphers, SNI=example.com)
S->>C: ServerHello + certificate (chain)
Note over C: verify cert chains to a trusted CA<br/>+ matches the hostname
C->>S: key exchange (ECDHE)
Note over C,S: both derive the same symmetric session key
C->>S: encrypted with AES-GCM
S->>C: encrypted with AES-GCM

Asymmetric crypto authenticates the server and agrees on a shared secret; that secret becomes a fast symmetric session key for the actual data. That’s why every TLS connection has a handshake phase then a (much faster) data phase — exactly the symmetric/asymmetric split from the previous page.

The chain of trust

Your OS ships a set of trusted root CA certificates. A website’s certificate is signed by an intermediate CA, which is signed by a root. Verification walks that chain up to a root you already trust — if any link is missing, expired, revoked, or untrusted, it fails.

See it: generate and verify a certificate

Go’s crypto/x509 builds and verifies certificates. This runs here — it generates a self-signed cert, parses it back, and verifies it against a pool containing itself. Output is fixed fields + booleans, so it’s deterministic:

cert.go — editable & runnable
package main

import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"fmt"
"math/big"
"time"
)

func main() {
// A key pair for the certificate.
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)

tmpl := &x509.Certificate{
	SerialNumber: big.NewInt(1),
	Subject:      pkix.Name{CommonName: "example.com"},
	DNSNames:     []string{"example.com"},
	NotBefore:    time.Now().Add(-time.Hour),
	NotAfter:     time.Now().Add(24 * time.Hour),
	IsCA:         true, // self-signed root for the demo
	KeyUsage:     x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
	BasicConstraintsValid: true,
}

// Self-sign: parent == template, signed with our own key.
der, _ := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
cert, _ := x509.ParseCertificate(der)

fmt.Println("subject:  ", cert.Subject.CommonName)
fmt.Println("is CA:    ", cert.IsCA)

// Verify the cert chains to a trusted root (itself, here).
roots := x509.NewCertPool()
roots.AddCert(cert)
_, err := cert.Verify(x509.VerifyOptions{Roots: roots, DNSName: "example.com"})
fmt.Println("verifies: ", err == nil)

// A hostname that isn't on the cert must fail.
_, err = cert.Verify(x509.VerifyOptions{Roots: roots, DNSName: "evil.com"})
fmt.Println("rejects wrong host:", err != nil)
}

Verify does the real work browsers do: chain-building to a trusted root, expiry checks, and hostname matching (evil.com is rejected because it’s not in the cert’s DNSNames). In production you’d let a real CA — or Let’s Encrypt via golang.org/x/crypto/acme/autocert — issue the cert, and the client’s system root pool would do the verifying.

Serving and consuming TLS

Running an HTTPS server is one line once you have a cert; mutual TLS adds client-cert verification for service-to-service auth (fenced — needs real certs and sockets):

// Server requiring client certificates (mTLS).
srv := &http.Server{
	Addr: ":8443",
	TLSConfig: &tls.Config{
		ClientAuth: tls.RequireAndVerifyClientCert,
		ClientCAs:  clientCAPool, // verify clients against these CAs
		MinVersion: tls.VersionTLS12,
	},
}
srv.ListenAndServeTLS("server.crt", "server.key")

mTLS is the backbone of zero-trust service meshes: every caller must present a valid certificate, so identity is cryptographic, not just a bearer token.

🐹 Let the platform do TLS, and pin versions

Don’t hand-roll certificate handling. For public services use Let’s Encrypt via autocert (automatic issuance and renewal); for internal services use your org’s CA or a service mesh that does mTLS for you. When you do configure tls.Config, set MinVersion: tls.VersionTLS12 (or 1.3), rely on Go’s sane default cipher suites, and use the system root pool for verification. Go’s TLS stack is excellent and safe by default — the danger is in the knobs you turn off.

⚠️ InsecureSkipVerify is how HTTPS becomes a lie

Setting InsecureSkipVerify: true in a tls.Config disables certificate verification — the client accepts any cert, including a man-in-the-middle’s. You still get encryption, but zero authenticity, so an attacker on the path can read and modify everything. It’s the most common way teams “fix” a cert error in development and then ship the foot-gun to production. If you need to trust a custom CA, add it to a RootCAs pool; if you need to trust one specific cert, pin its fingerprint — never blanket-skip verification.

See also

Next: the mistakes that break otherwise-good crypto — attacking weak crypto.

Check your understanding

Score: 0 / 5

1. What problem does a certificate (and the CA system) actually solve?

Encryption alone protects you from eavesdroppers but not from talking to the wrong party. A certificate binds a public key to an identity (a domain), and a Certificate Authority you already trust signs that binding. So when you connect to example.com, you can verify its cert chains up to a trusted CA — defeating man-in-the-middle impersonation.

2. In a TLS handshake, how are symmetric and asymmetric crypto combined?

Asymmetric crypto is slow, so TLS uses it only for the handshake: verify the server's certificate and run a key exchange (ECDHE) to agree on a shared secret. Both sides derive symmetric session keys from it, then switch to fast AES-GCM/ChaCha20 for the bulk data. Best of both worlds — authenticated key setup, fast encrypted transfer.

3. What is the 'chain of trust'?

Your OS/browser ships a set of trusted root CA certificates. A site's certificate is usually signed by an intermediate CA, which is signed by a root. To verify, the client builds and checks the chain up to a trusted root. If any link is missing, expired, or untrusted, verification fails. Go's crypto/x509 does this with Certificate.Verify against a CertPool.

4. What is mutual TLS (mTLS), and where is it used?

Normal TLS authenticates only the server (you verify the bank; the bank doesn't verify you cryptographically). mTLS adds client-certificate verification so both sides prove identity — ideal for service meshes and internal APIs where every caller should be a known, certificate-holding service. In Go you set tls.Config.ClientAuth = RequireAndVerifyClientCert.

5. Why is it dangerous to set InsecureSkipVerify: true on a TLS client?

InsecureSkipVerify skips the chain-of-trust check, so the client accepts a self-signed or attacker-supplied certificate without complaint. You still get encryption, but no authenticity — a MITM can present their own cert and read everything. It's a notorious foot-gun used to 'fix' cert errors in dev that then ships to production. Use a proper CA or pin a known cert instead.

Comments

Sign in with GitHub to join the discussion.