The Idea Was Sound. The Execution Was a Disaster.
The problem certificate pinning solves is real: Certificate Authorities can be compromised. In 2011, DigiNotar (a Dutch CA) was hacked, and attackers used it to issue fraudulent certificates for Google, Mozilla, and others — including *.google.com, which let them conduct man-in-the-middle attacks against Iranian users. DigiNotar was bankrupt and shut down within months.
The standard TLS trust model trusts all ~150 CAs in your browser’s root store. If any one of them misbehaves — or gets hacked — they can issue a valid certificate for your domain, and browsers will accept it.
Certificate pinning says: “I don’t care if your certificate is signed by a trusted CA. I only trust this specific certificate (or this specific public key).”
It’s a great idea. HPKP — the HTTP header mechanism for doing this in browsers — was one of the most spectacular self-destructions in web security history.
What HPKP Was
HTTP Public Key Pinning (RFC 7469, 2015) let servers send a response header telling browsers: remember these public key hashes, and for the next N days, only trust certificates that contain one of these keys.
Public-Key-Pins: pin-sha256="base64+hash="; pin-sha256="base64+backup-hash="; max-age=2592000
The intention: if a CA was compromised and issued a fraudulent cert for your domain, browsers would reject it because it wouldn’t match your pinned keys.
Why It Destroyed Itself
HPKP had a property that made every security engineer eventually stop recommending it: it was permanent, browser-enforced, and completely at the mercy of key management.
The horror scenario played out repeatedly:
- Admin deploys HPKP with
max-age=31536000(one year) - Certificate renews — new key generated (maybe by the CA, maybe automatically)
- The new certificate doesn’t match the pinned key
- Every browser that received the HPKP header now refuses to connect to the site. For a year.
- There’s nothing you can do. The browser won’t load the site. You can’t serve an update because browsers won’t connect. You can’t add a new pin because browsers won’t connect.
This is called HPKP suicide, and it happened to real companies. Some had to wait out the max-age period — literally waiting weeks or months for a security header they set to expire, watching their site be inaccessible.
HPKP also enabled something called HPKP hijacking: an attacker who briefly controls your site can set a malicious HPKP pin with a long max-age, then let their pin time out while everyone’s browser is still pinned to a key they don’t have. The attacker can’t access the site later — but neither can you.
Chrome removed HPKP support in version 72 (2019). Firefox followed. It’s dead.
Modern Alternatives That Don’t Murder Your Site
1. CAA DNS Records
CAA (Certification Authority Authorization) DNS records tell Certificate Authorities: “only these CAs are allowed to issue certificates for this domain.”
# Add CAA records to your DNS zone
# Format: flag tag value
# Only Let's Encrypt can issue certs for this domain
example.com. CAA 0 issue "letsencrypt.org"
# Only Let's Encrypt can issue wildcard certs
example.com. CAA 0 issuewild "letsencrypt.org"
# Send reports of unauthorized issuance attempts to this email
example.com. CAA 0 iodef "mailto:security@example.com"
# Check your CAA records
dig CAA example.com
CAs are required to check CAA records before issuing certificates. If a compromised CA tries to issue a cert for your domain, they’ll see the CAA record saying “only Let’s Encrypt allowed here” and (if they’re following the rules) refuse.
CAA doesn’t stop a rogue CA from ignoring the record — but it does create accountability, and violating CAA is now logged in Certificate Transparency.
2. Certificate Transparency (CT) Logs
Certificate Transparency is a public, append-only log of every certificate issued by participating CAs. Since 2018, Chrome requires that all certificates be logged in CT before browsers will trust them.
This means:
- Every certificate issued for your domain appears in a public log
- You can monitor for unexpected certificates
- A CA can’t issue a secret certificate for your domain without it appearing
Monitor CT logs for your domain:
# Use crt.sh to search CT logs
curl -s "https://crt.sh/?q=%.example.com&output=json" | \
python3 -c "import json,sys; [print(c['name_value']) for c in json.load(sys.stdin)]"
# Or use certspotter
apt install certspotter
# certspotter watches CT logs and notifies you of new certs
For automated monitoring, certspotter by SSLMate watches CT logs and emails you when new certificates are issued for your domain. Free for monitoring your own domains.
3. Expect-CT Header
Expect-CT was a lighter version of HPKP that required CT logging rather than pinning specific keys:
add_header Expect-CT "max-age=86400, enforce, report-uri='https://report.example.com'";
This tells browsers: if this certificate isn’t in the CT logs, reject it. Much safer than HPKP because CT logging is required anyway — so this header mostly just turns on strict enforcement with a reporting channel.
Expect-CT is now also deprecated since Chrome 107, because CT is required for all certificates anyway. But if you’re running older infrastructure, it’s a reasonable intermediate step.
Certificate Pinning in Application Code
For APIs and mobile apps — not browsers — certificate pinning still makes sense and is actively used. When you control both the client and the server, you can pin without the “browser ignores your header when it expires” problem.
Python: Pinning with requests
import hashlib
import ssl
import socket
import base64
from OpenSSL import crypto
def get_cert_fingerprint(hostname, port=443):
"""Get the SHA-256 fingerprint of a server's certificate."""
ctx = ssl.create_default_context()
with ctx.wrap_socket(
socket.socket(), server_hostname=hostname
) as s:
s.connect((hostname, port))
cert_der = s.getpeercert(binary_form=True)
fingerprint = hashlib.sha256(cert_der).digest()
return base64.b64encode(fingerprint).decode()
# Get your server's current fingerprint
print(get_cert_fingerprint("api.example.com"))
# --- In your client code ---
import requests
PINNED_FINGERPRINT = "your_base64_sha256_fingerprint_here"
def verify_pin(hostname, expected_fingerprint):
actual = get_cert_fingerprint(hostname)
if actual != expected_fingerprint:
raise ValueError(f"Certificate pinning failure: expected {expected_fingerprint}, got {actual}")
# Before making requests, verify the pin
verify_pin("api.example.com", PINNED_FINGERPRINT)
response = requests.get("https://api.example.com/data")
For a more robust approach, pin the public key (SPKI) rather than the full certificate — so you can renew the certificate without changing the key, and your clients keep working:
from OpenSSL import crypto
import hashlib, base64, ssl, socket
def get_spki_hash(hostname, port=443):
"""Get SHA-256 hash of the Subject Public Key Info."""
ctx = ssl.create_default_context()
with ctx.wrap_socket(socket.socket(), server_hostname=hostname) as s:
s.connect((hostname, port))
cert_der = s.getpeercert(binary_form=True)
cert = crypto.load_certificate(crypto.FILETYPE_ASN1, cert_der)
pub_key = crypto.dump_publickey(crypto.FILETYPE_ASN1, cert.get_pubkey())
return base64.b64encode(hashlib.sha256(pub_key).digest()).decode()
Go: Pinning with custom TLS verification
package main
import (
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"fmt"
"net/http"
)
const pinnedSPKI = "your_base64_encoded_sha256_of_spki_here"
func pinnedTransport(pinnedHash string) *http.Transport {
return &http.Transport{
TLSClientConfig: &tls.Config{
VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
for _, rawCert := range rawCerts {
cert, err := x509.ParseCertificate(rawCert)
if err != nil {
continue
}
// Hash the SPKI (SubjectPublicKeyInfo)
spkiHash := sha256.Sum256(cert.RawSubjectPublicKeyInfo)
encoded := base64.StdEncoding.EncodeToString(spkiHash[:])
if encoded == pinnedHash {
return nil // Pin matches, connection allowed
}
}
return fmt.Errorf("certificate pinning failure: no certificate matched pinned hash")
},
},
}
}
func main() {
client := &http.Client{Transport: pinnedTransport(pinnedSPKI)}
resp, err := client.Get("https://api.example.com/data")
if err != nil {
panic(err) // Pinning failure shows up here
}
defer resp.Body.Close()
// ...
}
Getting Certificate Fingerprints with OpenSSL
# Get the SHA-256 fingerprint of a live server's certificate
openssl s_client -connect example.com:443 </dev/null 2>/dev/null | \
openssl x509 -noout -fingerprint -sha256
# Get the SPKI hash (what you want to pin in application code)
openssl s_client -connect example.com:443 </dev/null 2>/dev/null | \
openssl x509 -noout -pubkey | \
openssl pkey -pubin -outform DER | \
openssl dgst -sha256 -binary | \
base64
# Get full certificate info
openssl s_client -connect example.com:443 </dev/null 2>/dev/null | \
openssl x509 -noout -text | head -50
# Check certificate expiry
openssl s_client -connect example.com:443 </dev/null 2>/dev/null | \
openssl x509 -noout -dates
Self-Signed Certs for Internal Tools
For internal services — your home lab’s Gitea instance, an internal API — self-signed certificates are fine as long as you distribute the CA cert to clients.
# Create your own CA
openssl genrsa -out ca.key 4096
openssl req -new -x509 -days 3650 -key ca.key -out ca.crt \
-subj "/C=US/O=HomeLabCA/CN=Home Lab Root CA"
# Create a server cert signed by your CA
openssl genrsa -out server.key 2048
openssl req -new -key server.key -out server.csr \
-subj "/C=US/O=HomeLab/CN=gitea.internal"
# Sign it
openssl x509 -req -days 365 -in server.csr \
-CA ca.crt -CAkey ca.key -CAcreateserial \
-out server.crt
# Trust your CA (on Debian/Ubuntu)
cp ca.crt /usr/local/share/ca-certificates/homelab-ca.crt
update-ca-certificates
# Now curl trusts your internal certs
curl https://gitea.internal
When Certificate Pinning Still Makes Sense
In order of “actually justified”:
- Mobile apps talking to your backend: Ship the SPKI hash in the app binary. Attackers who intercept traffic get pinning failures. Just have a backup pin for key rotation.
- Internal API clients in controlled environments: Pin the internal CA cert, not individual certs.
- High-security financial or medical applications: The key rotation complexity is worth it.
- Never for public websites: The HPKP graveyard exists for a reason. Use CAA records and CT monitoring instead.
The lesson HPKP taught us: security mechanisms that can lock you out of your own site are dangerous to deploy, no matter how sound the underlying idea is. CAA records, CT monitoring, and application-level pinning give you most of the benefit without the “my site is bricked for three months” failure mode.
Know your threat model. Match the tool to the risk.