Last quarter one of our regional cron workers in eu-west started returning video metadata that was subtly wrong: view counts off by orders of magnitude, thumbnail URLs pointing at a domain we didn't recognize, and a handful of records with an X-Cache: HIT header we never set. Nothing crashed. Nothing threw. The TLS handshake succeeded with a perfectly valid certificate. That is exactly the problem — a valid certificate is not the same thing as the certificate you meant to talk to. Somewhere between our box and the upstream API, a transparent proxy (a captive middlebox on the hosting provider's egress path, as it turned out) had terminated TLS with a cert its own root had happily signed. The chain validated. The data was tampered.
I run the backend for TrendVidStream, a global multi-region video discovery service that fans out to eight regions and pulls from several third-party streaming and metadata APIs on a cron cadence. When you make thousands of automated HTTPS calls a day from unattended workers, ordinary CA-based validation is not enough. Any of the ~150 root CAs your OS trusts — or any intermediate they've cross-signed, or any proxy your host silently injects — can produce a chain that passes. Certificate pinning closes that gap: you tell the client to accept a connection only if the server presents a public key you already know. This article is the pattern we landed on, why we pin the public key and not the certificate, and runnable PHP, Go, and Python for doing it without turning every cert rotation into an outage.
Why Pin the Public Key, Not the Certificate
There are three things people call certificate pinning, and only one of them is operable at scale.
- Certificate pinning (leaf): you hardcode the fingerprint of the exact leaf certificate. Simple, but it breaks the moment the server renews — which for Let's Encrypt is every 60 days and for many CDNs is unannounced.
- CA pinning: you pin the issuing CA. Survives rotation but trusts every certificate that CA will ever issue, which is a lot of certificates you did not vet.
- SPKI pinning (public key): you pin a SHA-256 hash of the Subject Public Key Info — the DER-encoded public key inside the cert. The key survives certificate renewal as long as the operator reuses the key pair (or you pin the intermediate's key), and it is the granularity curl, browsers (HPKP historically), and the mobile world all standardized on.
We pin SPKI. The pin is sha256// followed by a base64 SHA-256 of the DER public key. That format is not something I invented — it is exactly what libcurl's CURLOPT_PINNEDPUBLICKEY consumes, which means our PHP workers get pinning with a single option and no custom verify callback.
The one rule that keeps this from becoming a 3am incident: always pin at least two keys — the one in production and a backup key held offline for the next rotation. HPKP failed as a web standard largely because sites pinned a single key, lost it, and bricked themselves. Backup pins make rotation a config change instead of a resurrection.
Extracting the SPKI Pin
Before any code, you need the pin values. You compute them from the live endpoint (and, ideally, from the intermediate CA so you can rotate the leaf freely). Here is the exact pipeline — public key out of the cert, DER-encode it, SHA-256, base64:
#!/usr/bin/env bash
# spki-pin.sh — compute the SPKI pin for a host and its chain
set -euo pipefail
HOST="${1:?usage: spki-pin.sh host [port]}"
PORT="${2:-443}"
# Pull the full chain the server presents
openssl s_client -servername "$HOST" -connect "$HOST:$PORT" \
-showcerts </dev/null 2>/dev/null \
| awk '/BEGIN CERTIFICATE/{c++} {print > ("cert-" c ".pem")}'
for pem in cert-*.pem; do
subject=$(openssl x509 -in "$pem" -noout -subject 2>/dev/null || true)
[ -z "$subject" ] && continue
pin=$(openssl x509 -in "$pem" -pubkey -noout \
| openssl pkey -pubin -outform der 2>/dev/null \
| openssl dgst -sha256 -binary \
| openssl base64)
printf 'sha256//%s <- %s\n' "$pin" "$subject"
done
rm -f cert-*.pem
Run ./spki-pin.sh api.example-video.com. You get one pin per cert in the chain. Pin the leaf if the operator reuses keys across renewals; pin the intermediate if you want renewals to be transparent and only care that the cert was issued under a specific CA key. Our rule: pin the leaf as the primary and the intermediate as the backup. That way a routine leaf renewal (same or new key) still matches the intermediate pin, and a full CA migration still leaves us the offline backup key.
Store these somewhere versioned. We keep them in a small SQLite table (we already run SQLite with FTS5 for the discovery index, so there's no new dependency) keyed by API host, with a role column of primary or backup and a not_after hint so a nightly job can warn us before a pinned key expires.
Pinning in PHP With libcurl
This is where PHP 8.4 earns its keep. CURLOPT_PINNEDPUBLICKEY accepts a semicolon-separated list of sha256//... pins, and curl accepts the connection if the server's key matches any of them. That is your backup-pin mechanism for free. Here is the client we actually use, minus the project glue:
<?php
declare(strict_types=1);
final class PinnedVideoClient
{
/** @param list<string> $pins e.g. ['sha256//AAAA...=', 'sha256//BBBB...='] */
public function __construct(
private readonly string $baseUrl,
private readonly array $pins,
private readonly int $timeout = 15,
) {
if ($this->pins === []) {
throw new InvalidArgumentException('refusing to run without pins');
}
}
public function getJson(string $path): array
{
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => rtrim($this->baseUrl, '/') . '/' . ltrim($path, '/'),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FAILONERROR => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_CONNECTTIMEOUT => 5,
// Keep full CA validation ON. Pinning is defense in depth, not a replacement.
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,
// The pin(s). Any match accepts; no match aborts the handshake.
CURLOPT_PINNEDPUBLICKEY => implode(';', $this->pins),
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$body = curl_exec($ch);
$errno = curl_errno($ch);
$err = curl_error($ch);
curl_close($ch);
if ($errno === CURLE_SSL_PINNEDPUBKEYNOTMATCH) {
// errno 90 — the server key matched none of our pins.
throw new RuntimeException('SPKI pin mismatch — possible MITM, refusing data');
}
if ($errno !== 0) {
throw new RuntimeException("transport error ($errno): $err");
}
$data = json_decode((string) $body, true, flags: JSON_THROW_ON_ERROR);
return $data;
}
}
// Usage from a region worker:
$client = new PinnedVideoClient(
baseUrl: 'https://api.example-video.com',
pins: [
'sha256//K3x2...leafkey...=', // primary: current leaf key
'sha256//9fA1...intermediate...=', // backup: issuing CA key
],
);
$trending = $client->getJson('/v3/regions/eu-west/trending');
Two details matter more than they look. First, we keep CURLOPT_SSL_VERIFYPEER on. Pinning and CA validation are orthogonal checks; disabling the CA path to "simplify" is how people accidentally ship a client that accepts self-signed junk if the pin logic has a bug. Run both. Second, we treat CURLE_SSL_PINNEDPUBKEYNOTMATCH (errno 90) as a security event, not a transient error — no retry, no fallback to unpinned, and a log line loud enough to page someone. A pin mismatch on a host that worked yesterday means either the operator rotated to a key you didn't pre-authorize, or someone is in the path. Both need a human.
Doing It in Go When You Need the Handshake Details
Some of our ingestion runs as small Go binaries for the CPU-heavy transcode-metadata joins. Go's crypto/tls doesn't have a one-liner like curl, but tls.Config.VerifyConnection gives you the presented chain and lets you enforce the pin yourself. This is more code, but you get to log exactly which cert showed up, which is invaluable when a pin mismatch fires:
package main
import (
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"fmt"
"io"
"net/http"
"time"
)
// spkiPin returns the sha256//base64 pin for a parsed certificate.
func spkiPin(cert *x509.Certificate) string {
sum := sha256.Sum256(cert.RawSubjectPublicKeyInfo)
return "sha256//" + base64.StdEncoding.EncodeToString(sum[:])
}
func pinnedClient(allowed map[string]struct{}) *http.Client {
tr := &http.Transport{
ForceAttemptHTTP2: true,
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
VerifyConnection: func(cs tls.ConnectionState) error {
// Standard chain verification has already run at this point.
// We add: at least one cert in the presented chain must match a pin.
for _, cert := range cs.PeerCertificates {
if _, ok := allowed[spkiPin(cert)]; ok {
return nil
}
}
var seen string
if len(cs.PeerCertificates) > 0 {
seen = spkiPin(cs.PeerCertificates[0])
}
return fmt.Errorf("SPKI pin mismatch: leaf presented %s", seen)
},
},
}
return &http.Client{Transport: tr, Timeout: 15 * time.Second}
}
func main() {
allowed := map[string]struct{}{
"sha256//K3x2...leafkey...=": {},
"sha256//9fA1...intermediate...=": {},
}
client := pinnedClient(allowed)
resp, err := client.Get("https://api.example-video.com/v3/regions/us-east/trending")
if err != nil {
// A pin mismatch surfaces here as a tls handshake error.
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Printf("%d bytes\n", len(body))
}
Note RawSubjectPublicKeyInfo — that field is precisely the DER SPKI blob our openssl pipeline hashed, so the pins are identical across the bash script, PHP, and Go. VerifyConnection runs after Go's normal chain validation, so you keep CA checks and add the pin, same posture as the PHP client. If you ever need to pin while the CA path is intentionally custom, use VerifyPeerCertificate with InsecureSkipVerify: true and do full manual verification — but that is a footgun and I'd avoid it unless you truly control both ends.
The Python Client and the assert_fingerprint Trap
Our one-off backfill scripts are Python. The trap here is that requests/urllib3 ships assert_fingerprint, and people reach for it thinking it's SPKI pinning. It is not — it fingerprints the whole certificate, so it breaks on every renewal, exactly the fragile leaf-cert pinning we ruled out. To get real SPKI pinning you drop to a custom adapter that inspects the socket's DER cert after connect:
import hashlib
import base64
import ssl
from requests.adapters import HTTPAdapter
from urllib3.poolmanager import PoolManager
import requests
def spki_pin_from_der(der_cert: bytes) -> str:
# Extract SPKI from the DER cert and hash it, matching openssl/curl.
cert = ssl.DER_cert_to_PEM_cert(der_cert)
from cryptography import x509
from cryptography.hazmat.primitives import serialization
parsed = x509.load_pem_x509_certificate(cert.encode())
spki_der = parsed.public_key().public_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
digest = hashlib.sha256(spki_der).digest()
return "sha256//" + base64.b64encode(digest).decode()
class SPKIPinnedAdapter(HTTPAdapter):
def __init__(self, allowed_pins, **kw):
self._allowed = set(allowed_pins)
super().__init__(**kw)
def init_poolmanager(self, connections, maxsize, block=False, **kw):
ctx = ssl.create_default_context()
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
self.poolmanager = PoolManager(
num_pools=connections, maxsize=maxsize, block=block,
ssl_context=ctx, **kw,
)
def send(self, request, **kw):
resp = super().send(request, **kw)
sock = getattr(resp.raw, "_connection", None)
conn = getattr(resp.raw, "_original_response", None)
der = None
if conn is not None:
der = conn.fp.raw._sock.getpeercert(binary_form=True)
if der is None:
raise requests.exceptions.SSLError("could not read peer cert for pinning")
pin = spki_pin_from_der(der)
if pin not in self._allowed:
raise requests.exceptions.SSLError(f"SPKI pin mismatch: got {pin}")
return resp
session = requests.Session()
session.mount("https://api.example-video.com", SPKIPinnedAdapter([
"sha256//K3x2...leafkey...=",
"sha256//9fA1...intermediate...=",
]))
r = session.get("https://api.example-video.com/v3/regions/ap-south/trending", timeout=15)
r.raise_for_status()
print(len(r.content), "bytes")
The honest caveat: this checks the pin after the response is read, so it is a defense-in-depth verification rather than a handshake-time abort — for a stricter guarantee, do the pin check inside a custom HTTPSConnection.connect. For our backfill scripts the post-hoc check is acceptable because we discard any response whose pin failed and never persist it. If you're pinning a login or payment call, do it at connect time.
Rotation, Monitoring, and Not Bricking Yourself
Pinning fails safe only if you operate it. The mechanics we settled on across all eight regions:
- Two pins minimum, always. Primary is the live key; backup is an offline key you generated in advance and can swap to without a code change. Rotation is: deploy the new key server-side (already in the client's backup pin) → promote it → generate the next backup → update the pin config.
- Config-driven, not hardcoded. Pins live in the SQLite table, not in source, so our FTP-based deploy pushes a pin update the same way it pushes any config. A pin change never requires a rebuild.
-
Expiry warnings. A nightly cron reads each pinned host's
not_afterand warns 21 days out. A pin whose only cert is about to expire, with no backup that outlives it, is a scheduled outage. - Mismatch is a page, not a retry. Never fall back to an unpinned request on mismatch. Falling back defeats the entire control — an attacker just has to make the pinned attempt fail. Log it, alert, stop.
- Pin the intermediate for high-churn CDNs. If an upstream sits behind a CDN that rotates leaf keys constantly, pinning the leaf is a maintenance treadmill. Pin the CA intermediate's key instead; you still exclude the ~150 other roots, which is the threat you actually care about.
Here is the tiny check-and-log helper that ties the PHP client to our SQLite pin store and turns a mismatch into a durable record:
<?php
declare(strict_types=1);
function pinsForHost(PDO $db, string $host): array
{
$stmt = $db->prepare(
'SELECT pin FROM api_pins WHERE host = :h ORDER BY role' // primary, then backup
);
$stmt->execute([':h' => $host]);
return $stmt->fetchAll(PDO::FETCH_COLUMN) ?: [];
}
function recordPinFailure(PDO $db, string $host, string $region): void
{
$db->prepare(
'INSERT INTO pin_failures (host, region, seen_at) VALUES (:h, :r, :t)'
)->execute([':h' => $host, ':r' => $region, ':t' => time()]);
error_log("SECURITY: SPKI pin mismatch host=$host region=$region");
}
After we rolled pinning out, the eu-west tampering that started this whole investigation would have failed loudly at the handshake with errno 90 instead of quietly poisoning our discovery index for a day. That is the entire point: certificate pinning does not make your TLS "more encrypted," it makes your client refuse to talk to anything but the servers you actually chose to trust. For an unattended fleet making thousands of API calls across regions you don't control, that refusal is worth every line of the verify callback.
Conclusion
CA validation answers "is this a valid certificate." Pinning answers "is this my server's key" — a different, stronger question, and the one that matters when your traffic crosses proxies and egress paths you can't audit. Pin the SPKI, not the leaf cert; always carry a backup pin so rotation is a config edit and not an outage; keep CA validation on underneath; and treat a mismatch as a security event that stops the pipeline rather than a soft error you retry around. The openssl pin you compute is byte-for-byte the same value libcurl, Go's RawSubjectPublicKeyInfo, and Python's cryptography produce, so one set of pins secures a polyglot fleet. It took us an afternoon to implement and it has quietly rejected two genuine middlebox interceptions since. Cheap insurance for anything that ingests data you didn't generate.
Top comments (0)