Zum Inhalt springen
ecdsa.com
← Alle Artikel

Certificate Pinning 2026: wann und wie du sicher pinnst

Pinning tauscht die Flexibilität der öffentlichen PKI gegen Schutz vor einem seltenen Fehlerfall: Eine vertrauenswürdige CA stellt jemand anderem ein Zertifikat für deinen Domainnamen aus. Was dieser Tausch 2026 kostet, zeigt dieser Artikel mit Pins, die für eine echte Kette berechnet und in OkHttp 5.5.0 sowie in Apples App Transport Security unter macOS 27 getestet wurden.

Kurz gesagt. Die Browser haben das headerbasierte Pinning (HPKP) aufgegeben: Chrome hat es in Version 72 (Januar 2019) entfernt, Firefox in Version 72 (Januar 2020), und Certificate Transparency deckt heute das meiste von dem ab, was HPKP abfangen sollte. Weiter lebt Pinning in mobilen Apps und API-Clients, die mit Servern unter deiner Kontrolle kommunizieren. Wenn du pinnst, dann pinne den SHA-256-Hash eines öffentlichen Schlüssels (SPKI), nicht eines Zertifikats; pinne Schlüssel von CAs, die du weiter nutzen wirst (Zwischen- oder Root-Zertifikat), statt deines Leaf-Schlüssels, den die meisten ACME-Clients bei jeder Erneuerung ersetzen; liefere immer mindestens einen Backup-Pin mit aus, für eine andere CA oder einen Offline-Schlüssel; und nutze den Mechanismus der Plattform: die Network Security Configuration unter Android, dort mit einem Ablaufdatum, oder NSPinnedDomains unter iOS. Google und Apple raten beide vom Pinning ab, solange du keinen konkreten Grund dafür hast.

Was ein Pin ist

Die normale Zertifikatsvalidierung akzeptiert jede Kette, die zu irgendeinem Root-Zertifikat im Trust Store führt. Ein Pin fügt eine weitere Bedingung hinzu: Mindestens ein Schlüssel der validierten Kette muss auf deiner Liste stehen. Die Liste enthält Hashes der SubjectPublicKeyInfo (SPKI): Algorithmuskennung und öffentlicher Schlüssel, DER-kodiert. Weil der Schlüssel gehasht wird und nicht das Zertifikat, passt ein erneuertes Zertifikat mit demselben Schlüssel weiterhin, und ein Pin auf einen CA-Schlüssel passt zu jedem Zertifikat, das diese CA ausstellt.

Die Pins berechnest du mit OpenSSL aus den Zertifikaten. Hier für die Kette, die letsencrypt.org am 20. Oktober 2026 ausgeliefert hat (eclipse-temurin:25.0.4.1_1-jdk-noble, OpenSSL 3.0.13):

SPKI-Pin jedes Zertifikats, das ein Server sendet
openssl s_client -connect letsencrypt.org:443 -servername letsencrypt.org -showcerts </dev/null 2>/dev/null \
  | awk '/-----BEGIN CERTIFICATE-----/{n++; f=sprintf("c%d.pem", n); p=1} p{print >> f} /-----END CERTIFICATE-----/{p=0; close(f)}'
pin() { openssl x509 -in "$1" -noout -pubkey | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64; }
for f in c*.pem; do printf '%s  %s\n' "$(pin "$f")" "$(openssl x509 -in "$f" -noout -subject | sed 's/^subject=//')"; done
Ausgabe
x8ltqh+zNPYG7RZS+IjFymndoy6YAWLPaSkptqebzfc=  CN = letsencrypt.org
s/tdAOmUzd8syaTuqfgGvFcn6DzA5Cmb+Vby1ST+U3Y=  C = US, O = Let's Encrypt, CN = YE2
sCkq5UWXjg+7mKu9lMhhYF5bGLsy7VI/UNW3tccdR7w=  C = US, O = ISRG, CN = Root YE
diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=  C = US, O = Internet Security Research Group, CN = ISRG Root X2

Die vier Kandidaten verhalten sich im Lauf der Zeit sehr unterschiedlich, und die Wahl zwischen ihnen ist der größte Teil der Arbeit. Der SPKI-Pin-Generator berechnet dieselben Werte im Browser und gibt Snippets für die Konfiguration aus.

Warum die Browser aufgegeben haben: HPKP

HTTP Public Key Pinning (RFC 7469, 2015) erlaubte einer Website, einen Header Public-Key-Pins mit Hashes von Schlüsseln und einem max-age zu senden; der Browser lehnte danach für diese Dauer jede Kette ab, die keinen dieser Schlüssel enthielt. Gescheitert ist das Verfahren an zwei Fehlerbildern:

  • Selbst verschuldete Ausfälle. Ein verlorener Schlüssel, eine gewechselte CA oder ein Tippfehler sperrte jeden wiederkehrenden Besucher aus, bis max-age abgelaufen war, und vom Server aus ließ sich das nicht rückgängig machen.
  • Feindliches Pinning. Ein Angreifer, der kurzzeitig eine Website (oder deren Zertifikatsausstellung) unter Kontrolle hatte, konnte Pins für Schlüssel senden, die nur er selbst besaß, und so eine kurze Kompromittierung in einen langen Denial of Service verwandeln.

Der Eintrag zur Entfernung in Chrome nennt eine „sehr geringe Verbreitung“ und die „Risiken von Denial of Service und feindlichem Pinning“ (chromestatus); die Unterstützung wurde in Chrome 72 auf dem Desktop und unter Android entfernt, und Firefox folgte mit Version 72. Browser bringen noch immer eine kleine eingebaute Pin-Liste für die eigenen Domains ihrer Hersteller mit, aber keine Website kann sich selbst darauf setzen.

An die Stelle von HPKP trat Erkennung statt Verhinderung. Seit 2018 akzeptieren Chrome und Apples Plattformen öffentlich vertrauenswürdige TLS-Zertifikate nur mit Signed Certificate Timestamps aus Certificate-Transparency-Logs, sodass jedes Zertifikat für deinen Domainnamen öffentlich ist und überwacht werden kann; CAA-Einträge legen fest, welche CAs für deine Domain ausstellen dürfen. Zusammen machen sie die Fehlausstellungen, gegen die sich HPKP richtete, innerhalb von Stunden sichtbar – ohne das Risiko, Besucher auszusperren.

Was du pinnen solltest

PinÜberstehtSchlägt fehl
Leaf-SchlüsselErneuerungen, die den Schlüssel beibehaltenBei jeder Erneuerung mit neuem Schlüssel, und das ist bei certbot der Standard
Ausstellendes Zwischenzertifikat (YE2)Erneuerungen und Schlüsselwechsel des Leaf-ZertifikatsWenn die CA ihre Zwischenzertifikate rotiert; Let's Encrypt hält mehrere in Rotation und dazu Backups, aus denen die CA „jederzeit und ohne Vorwarnung“ mit der Ausstellung beginnen kann
Root-Zertifikat (ISRG Root X2)Rotation der ZwischenzertifikateWenn du die CA wechselst oder der CA das Vertrauen entzogen wird
Schlüssel, den du offline aufbewahrstAlles, solange er ungenutzt bleibtNur wenn du ihn verlierst

Die Leaf-Zeile verdient besondere Aufmerksamkeit, weil genau hier die Voreinstellung zur Falle wird. certbot --help all (v5.8.0) beschreibt --reuse-key als standardmäßig deaktiviert und sagt: „Private Schlüssel nicht wiederzuverwenden ist das Standardverhalten von Certbot“. Ein Leaf-Pin schlägt deshalb bei der nächsten Erneuerung fehl, und die Erneuerungen rücken immer enger zusammen: Das CA/Browser Forum begrenzt TLS-Zertifikate seit März 2026 auf 200 Tage, ab März 2027 auf 100 Tage und ab März 2029 auf 47 Tage, und die Standardlaufzeit bei Let's Encrypt sinkt im Februar 2027 auf 64 Tage.

Tückischer ist die Zeile mit dem Zwischenzertifikat. Das Zertifikat von letsencrypt.org wurde von YE2 ausgestellt, aber Let's Encrypt führt YE1 und YE2 (ECDSA) sowie YR1 und YR2 (RSA) in aktiver Rotation, mit YE3 und YR3 als Backups (Vertrauensketten). Ein Pin-Set, das nur YE2 enthält, schlägt beim nächsten Zertifikat von YE1 fehl.

Ein Root-Pin übersteht all das und schlägt nur fehl, wenn du die CA wechselst. Das passiert öfter als erwartet: Als Chrome den von Entrust ausgestellten Zertifikaten das Vertrauen entzog, deren frühester SCT nach dem 11. November 2024 lag (Google Security Blog), mussten alle Entrust-Kunden innerhalb weniger Monate zu einer anderen CA wechseln. Eine App, die ohne Backup auf Root-Zertifikate von Entrust gepinnt war, hätte vorher ein Update gebraucht.

Der übliche Kompromiss: Pinne das Root-Zertifikat (oder beide Root-Zertifikate) deiner aktuellen CA, dazu das Root-Zertifikat einer zweiten CA, zu der du wechseln könntest, und optional einen Schlüssel, den du selbst erzeugt hast und offline für ein Notfallzertifikat aufbewahrst. Mit Let's Encrypt als aktueller CA und Google Trust Services als Ausweichlösung, aus ca-certificates 20260816 von Debian:

Root-Pins (dieselbe openssl-Pipeline auf /etc/ssl/certs/*.pem)
C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=  subject=C=US, O=Internet Security Research Group, CN=ISRG Root X1 notAfter=Jun  4 11:04:38 2035 GMT
diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=  subject=C=US, O=Internet Security Research Group, CN=ISRG Root X2 notAfter=Sep 17 16:00:00 2040 GMT
mEflZT5enoR1FuXLgYYGqnVEoZvmf9c2bVBpiOjYQ0c=  subject=C=US, O=Google Trust Services LLC, CN=GTS Root R4 notAfter=Jun 22 00:00:00 2036 GMT
hxqRlPTu1bMS/0DITB1SSu0vd4u/8l8TjPgfaAp63Gc=  subject=C=US, O=Google Trust Services LLC, CN=GTS Root R1 notAfter=Jun 22 00:00:00 2036 GMT

Der Pin für ISRG Root X2 passt auf das letzte Zertifikat, das letsencrypt.org sendet (X2, von X1 cross-signiert), weil es derselbe Schlüssel ist wie beim selbstsignierten X2 in den Trust Stores. Das Root-Zertifikat einer anderen CA zu pinnen, die du noch nicht nutzt, kostet nichts und ist dein Notausgang.

Android: Network Security Configuration

Android 7.0 und neuer lesen Pins aus der Datei res/xml/network_security_config.xml, auf die im Manifest das Attribut android:networkSecurityConfig verweist (Dokumentation):

res/xml/network_security_config.xml: Root-Zertifikate der aktuellen CA plus eine zweite CA, mit Ablaufdatum
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <domain-config>
        <domain includeSubdomains="true">api.example.com</domain>
        <pin-set expiration="2027-10-01">
            <!-- ISRG Root X2 and X1: the current CA, ECDSA and RSA chains -->
            <pin digest="SHA-256">diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=</pin>
            <pin digest="SHA-256">C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=</pin>
            <!-- GTS Root R4 and R1: the CA we would switch to -->
            <pin digest="SHA-256">mEflZT5enoR1FuXLgYYGqnVEoZvmf9c2bVBpiOjYQ0c=</pin>
            <pin digest="SHA-256">hxqRlPTu1bMS/0DITB1SSu0vd4u/8l8TjPgfaAp63Gc=</pin>
        </pin-set>
    </domain-config>
</network-security-config>

Die Regeln, auf die es ankommt, laut Dokumentation:

  • Eine Kette wird akzeptiert, wenn sie „mindestens einen der gepinnten öffentlichen Schlüssel enthält“; der einzige Digest ist SHA-256.
  • Nach dem Datum in expiration (yyyy-MM-dd) findet kein Pinning mehr statt. Das hält alte Installationen am Laufen, auch wenn du sie längst vergessen hast; die Dokumentation warnt aber auch, dass es auf Geräten mit veralteten Apps „Angreifern ermöglichen kann, deine gepinnten Zertifikate zu umgehen“. Wähle ein Datum etwa einen Release-Zyklus nach dem Termin, zu dem du die Pins das nächste Mal aktualisierst.
  • Vertrauensanker in <debug-overrides> umgehen die Pins, wenn die App debugfähig ist; so untersuchst du den Datenverkehr während der Entwicklung. overridePins="true" an einem Element <certificates> bewirkt dasselbe in Release-Builds und sollte nicht ausgeliefert werden.
  • Android 16 hat in derselben Datei Prüfungen für Certificate Transparency ergänzt (<certificateTransparency enabled="true"/>), und ab Android 17 sind sie standardmäßig aktiv für Apps mit Android 17 als Ziel-SDK, außer bei Verbindungen mit eigenen Vertrauensankern. Für viele Apps ist das der Schutz, den sie sich vom Pinning erhofft hatten.

Googles eigene Empfehlung ist unverblümt: Pinning ist „für Android-Apps nicht empfohlen“, und wenn du es trotzdem einsetzt, sollst du „mehrere Backup-Pins, darunter mindestens einen Schlüssel, der vollständig unter deiner Kontrolle steht, und eine ausreichend kurze Ablauffrist“ vorsehen (Sicherheit mit Netzwerkprotokollen).

OkHttp

Der CertificatePinner von OkHttp funktioniert unter Android und auf der JVM, wo es keine Network Security Configuration gibt. Der Test pinnt letsencrypt.org zuerst auf ISRG Root X2 mit X1 als Backup, dann auf einen Schlüssel, den keine CA zertifiziert hat – so sieht es für eine App aus, nachdem der Server zu einer nicht gepinnten CA umgezogen ist:

Pin.java (OkHttp 5.5.0, Okio 3.18.1, Kotlin stdlib 2.1.21, Java 25)
// OkHttp CertificatePinner against letsencrypt.org: a correct CA-key pin, a backup pin, and a wrong pin.
import java.io.IOException;
import okhttp3.CertificatePinner;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;

public class Pin {
    static void get(String label, CertificatePinner pinner) {
        OkHttpClient client = new OkHttpClient.Builder().certificatePinner(pinner).build();
        Request request = new Request.Builder().url("https://letsencrypt.org/robots.txt").build();
        try (Response response = client.newCall(request).execute()) {
            System.out.println(label + ": HTTP " + response.code());
        } catch (IOException e) {
            System.out.println(label + ": " + e.getClass().getName() + ": " + e.getMessage());
        }
    }

    public static void main(String[] args) {
        // ISRG Root X2 key, plus a backup pin for ISRG Root X1 (RSA): either satisfies the pin set.
        get("pinned to ISRG Root X2 (+ X1 backup)", new CertificatePinner.Builder()
            .add("letsencrypt.org", "sha256/" + args[0])
            .add("letsencrypt.org", "sha256/" + args[1])
            .build());
        // A key that is not in the chain: what a client sees after the site moves to another CA.
        get("pinned to an unrelated key", new CertificatePinner.Builder()
            .add("letsencrypt.org", "sha256/" + args[2])
            .build());
    }
}
java -Djava.net.preferIPv4Stack=true -cp okhttp-jvm-5.5.0.jar:okio-jvm-3.18.1.jar:kotlin-stdlib-2.1.21.jar Pin.java …
pinned to ISRG Root X2 (+ X1 backup): HTTP 200
pinned to an unrelated key: javax.net.ssl.SSLPeerUnverifiedException: Certificate pinning failure!
  Peer certificate chain:
    sha256/x8ltqh+zNPYG7RZS+IjFymndoy6YAWLPaSkptqebzfc=: CN=letsencrypt.org
    sha256/s/tdAOmUzd8syaTuqfgGvFcn6DzA5Cmb+Vby1ST+U3Y=: CN=YE2, O=Let's Encrypt, C=US
    sha256/sCkq5UWXjg+7mKu9lMhhYF5bGLsy7VI/UNW3tccdR7w=: CN=Root YE, O=ISRG, C=US
    sha256/diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=: CN=ISRG Root X2, O=Internet Security Research Group, C=US
  Pinned certificates for letsencrypt.org:
    sha256/VIJvW/e8yv/LvjcOADWb4C9aNclxEiQeW0tr4SvazsA=

Die Fehlermeldung listet die Pins der Kette auf, die OkHttp validiert hat, bis hin zum Vertrauensanker, und die Dokumentation von OkHttp selbst schlägt genau das als Methode zur Einrichtung vor: Beginne mit einem falschen Pin in einem vertrauenswürdigen Netzwerk und kopiere die erwarteten Werte aus der Exception. Die Pins stimmen mit der OpenSSL-Ausgabe von oben überein. Derselbe Kommentar enthält eine Warnung, die du jedem gegenüber wiederholen solltest, der die Änderung freigibt: „Setze Certificate Pinning nicht ohne den Segen des TLS-Administrators deines Servers ein!“ Außerdem läuft CertificatePinner erst nach der normalen Validierung und kann daher kein selbstsigniertes Zertifikat akzeptabel machen.

Eine Debugging-Falle, in die wir getappt sind: Der Container hatte keine IPv6-Route, und ohne -Djava.net.preferIPv4Stack=true meldete der zweite Aufruf java.net.SocketException: Network is unreachable. OkHttp 5 probiert Adressen parallel aus („Fast Fallback“), und der Pinning-Fehler an der IPv4-Adresse landete als unterdrückte Exception hinter dem Verbindungsfehler über IPv6; mit fastFallback(false) war der Pinning-Fehler die primäre Exception. Wenn Pinning im Produktivbetrieb fehlschlägt, protokolliere auch getSuppressed().

iOS und macOS: NSPinnedDomains

Seit iOS 14 und macOS 11 pinnt App Transport Security Schlüssel, die in Info.plist deklariert sind. Apples Artikel „Identity Pinning“ beschreibt zwei Listen pro Domain: NSPinnedCAIdentities (der Schlüssel muss in einem Zwischen- oder Root-Zertifikat der Kette vorkommen) und NSPinnedLeafIdentities (der eigene Schlüssel des Servers); jeder Eintrag ist ein Wert unter SPKI-SHA256-BASE64, berechnet mit derselben OpenSSL-Pipeline:

Info.plist (Auszug): letsencrypt.org auf den Schlüssel von ISRG Root X2 pinnen
<key>NSAppTransportSecurity</key>
<dict>
    <key>NSPinnedDomains</key>
    <dict>
        <key>letsencrypt.org</key>
        <dict>
            <key>NSPinnedCAIdentities</key>
            <array>
                <dict>
                    <key>SPKI-SHA256-BASE64</key>
                    <string>diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=</string>
                </dict>
            </array>
        </dict>
    </dict>
</dict>

Wir haben das Verhalten auf diesem Rechner geprüft (macOS 27.0.1, Apple M4 Max, Swift 6.4): mit einem Befehlszeilentool, das https://letsencrypt.org/robots.txt per URLSession abruft und dreimal mit swiftc main.swift -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist -Xlinker Info.plist gebaut wurde: ohne Pins, mit dem X2-Pin von oben und mit dem fremden Schlüssel aus dem OkHttp-Test:

Ausgabe der drei Builds
HTTP 200
HTTP 200
error: NSURLErrorDomain -1200: A TLS error caused the secure connection to fail.

Die App erhält den generischen Fehler NSURLErrorSecureConnectionFailed (-1200), keinen Pinning-spezifischen Code. Halte deshalb fest, mit welchem Pin-Set jede App-Version ausgeliefert wurde; sonst sieht ein nicht passender Pin nach einer Änderung am Server in Absturzberichten aus wie jeder andere TLS-Fehler. Die Einstellung NSIncludesSubdomains erweitert einen Pin nur auf eine Ebene von Subdomains (math.example.org, nicht advanced.math.example.org). Apples Empfehlung deckt sich mit der von Google: „In den meisten Fällen ist Pinning nicht nötig und sollte vermieden werden“, und wenn du pinnst, dann CA-Schlüssel und mehr als einen.

Pins rotieren, ohne jemanden auszusperren

Ein Pin-Set ist ein Versprechen über die Zukunft, und die App, die es enthält, wird womöglich ein Jahr lang nicht aktualisiert. Eine Rotation, die funktioniert:

  1. Liefere den neuen Pin aus, bevor du ihn brauchst. Nimm in einem Release das Root-Zertifikat der nächsten CA oder den nächsten Schlüssel ins Pin-Set auf und warte, bis die Versionen ohne ihn nur noch einen Anteil der Nutzer ausmachen, bei dem du dir einen Ausfall leisten kannst.
  2. Ändere erst dann den Server. Wechsle die CA oder den Schlüssel; die alten Pins passen in alten App-Versionen weiterhin, solange sie irgendeinen Schlüssel aus der neuen Kette enthalten.
  3. Entferne ausgemusterte Pins in einem späteren Release.
  4. Halte einen Notfallweg bereit. Ein Offline-Schlüssel, der gepinnt, aber nie ausgerollt ist, erlaubt dir, im Notfall bei jeder beliebigen CA ein Zertifikat dafür zu bekommen. Manche Apps laden Pin-Updates außerdem aus einer signierten Remote-Konfiguration; diese Datei braucht dann einen eigenen Signaturschlüssel, der außerhalb der TLS-Infrastruktur aufbewahrt wird.
  5. Setze unter Android ein Ablaufdatum und behandle es als Frist für die nächste Rotation, nicht als Formalität.
  6. Teste den Fehlerfall. Lass die App gegen einen Proxy laufen, dessen Zertifikat aus deinem Trust Store stammt, aber nicht im Pin-Set liegt, und prüfe, dass sie die Verbindung abbricht (Fail-closed) und dabei etwas Brauchbares meldet.

Proxys zur TLS-Inspektion in Unternehmen legen gepinnte Apps konstruktionsbedingt lahm: Deren Zertifikate führen auf ein Root-Zertifikat des Unternehmens, das nicht gepinnt ist. Unter Android vertrauen Apps mit Ziel-API-Level 24 oder höher den vom Benutzer installierten CAs nicht, sofern die Konfiguration sie nicht hinzufügt, und auch dann gelten die Pins für sie, solange overridePins nicht gesetzt ist.

Häufige Fehler

  • Nur den Leaf-Schlüssel pinnen bei einem ACME-Client, der die Schlüssel rotiert. Die nächste Erneuerung wird zum Ausfall.
  • Nur ein Zwischenzertifikat pinnen bei einer CA, die mehrere rotiert. Das funktioniert, bis die CA ein anderes wählt.
  • Kein Backup-Pin, oder ein Backup von derselben CA. Wird dieser CA das Vertrauen entzogen, schlagen beide Pins gemeinsam fehl.
  • Den Hash des Zertifikats statt der SPKI pinnen. Jede Erneuerung macht ihn wertlos, selbst bei gleichem Schlüssel.
  • Selbst geschriebene Trust Manager. Die Validierung der Plattform zu ersetzen, um „Pinning hinzuzufügen“, entfernt oft versehentlich die Prüfung von Hostname oder Kette. Nutze Network Security Configuration, CertificatePinner oder NSPinnedDomains, die zusätzlich zur normalen Validierung pinnen.
  • Erwarten, dass Pins vor einem kompromittierten Gerät schützen. Auf einem gerooteten oder gejailbreakten Gerät kann der Nutzer (oder Malware) das Pinning herauspatchen. Pinning schützt die Nutzer vor dem Netzwerk, nicht deine API vor deinen Nutzern.

Quellen