Certificate pinning makes a client accept a TLS connection only when the server's certificate chain contains a specific public key, instead of trusting any certificate signed by any CA in the system store. It protects API traffic from mobile apps, agents and internal clients against a misissued certificate or a compromised CA, but a wrong pin locks your own clients out until you ship an update. In this tutorial you will generate SPKI pins with OpenSSL on Ubuntu 24.04, choose what to pin, keep the pinned key stable across Let's Encrypt renewals, prepare a backup pin, and test everything with curl before configuring a real client.

Prerequisites

To follow this guide you need:

  • A server running Ubuntu 24.04 LTS, such as a CubePath VPS, with a non-root user that has sudo privileges.
  • A domain, referred to as your_domain, served over HTTPS. The examples assume a Let's Encrypt certificate managed by Certbot.
  • A client you control and can update: a mobile app, a CLI tool, an IoT agent or a service calling your API.

What to pin

A pin is the base64-encoded SHA-256 hash of a certificate's Subject Public Key Info (SPKI), not of the certificate itself. Because it hashes only the key, the pin survives a renewal as long as the key does not change. You can pin keys at different levels of the chain:

Pin targetSurvives renewalsProtectionOperational risk
Your leaf keyOnly if the key is reusedHighest: only your key is acceptedHigh: key loss or rotation breaks clients without a backup pin
CA intermediate keyNo: Let's Encrypt picks among several intermediates, which change over timeMediumHigh, and not recommended
CA root keyYes, for yearsOnly your chosen CA can issue for youLow

Two strategies work in practice:

  • Pin the CA roots, for example ISRG Root X1 and ISRG Root X2 for Let's Encrypt. Renewals and key changes are invisible to clients, and a certificate from any other CA is rejected.
  • Pin your own leaf keys, always shipping the current key and at least one backup key that is stored offline. This is the strictest option and needs disciplined key management.

Clients accept the connection when any pin in the set matches a key in the validated chain, so every pin set must contain at least two pins.

Step 1 - Generating the pin of a live server

Fetch the leaf certificate from the server, extract its public key, and hash it:

openssl s_client -connect your_domain:443 -servername your_domain </dev/null 2>/dev/null \
  | openssl x509 -pubkey -noout \
  | openssl pkey -pubin -outform der \
  | openssl dgst -sha256 -binary \
  | openssl enc -base64
eHljkwVccz/yoWG1ji21rv4983BsFEDDvbpNNnosPlU=

Your value will differ. On the server itself, you can hash the key file directly and confirm you get the same pin:

sudo openssl pkey -in /etc/letsencrypt/live/your_domain/privkey.pem -pubout -outform der \
  | openssl dgst -sha256 -binary | openssl enc -base64

If the two values differ, the server is not serving the certificate you think it is, and you must fix that before pinning anything.

Step 2 - Generating pins for the CA roots

Ubuntu's ca-certificates package already contains the Let's Encrypt roots, so you can hash them locally:

for root in ISRG_Root_X1 ISRG_Root_X2; do
  printf '%s: ' "$root"
  openssl x509 -in "/etc/ssl/certs/${root}.pem" -pubkey -noout \
    | openssl pkey -pubin -outform der \
    | openssl dgst -sha256 -binary | openssl enc -base64
done
ISRG_Root_X1: C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=
ISRG_Root_X2: diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=

If you use another CA, take the root certificates from its documentation and hash them the same way. Consider adding the root of a second CA as a backup, so you can switch providers without an app release.

Step 3 - Keeping the leaf key stable across renewals

This step only matters if you pin your leaf key. By default Certbot generates a new private key on every renewal, which would change the pin every 60 to 90 days. Each certificate's renewal settings live in /etc/letsencrypt/renewal/; open the file for your domain:

sudo nano /etc/letsencrypt/renewal/your_domain.conf

Add reuse_key = True in the [renewalparams] section, keeping the lines Certbot already wrote there:

[renewalparams]
account = 0123456789abcdef0123456789abcdef
authenticator = nginx
installer = nginx
server = https://acme-v02.api.letsencrypt.org/directory
reuse_key = True

The account value is specific to your server; leave it as it is. Confirm the setting:

sudo grep reuse_key /etc/letsencrypt/renewal/your_domain.conf
reuse_key = True

From now on the pin from Step 1 stays the same after every renewal. Run a dry run to make sure the renewal file still parses and the renewal works:

sudo certbot renew --cert-name your_domain --dry-run
Congratulations, all simulated renewals succeeded:
  /etc/letsencrypt/live/your_domain/fullchain.pem (success)

After the next real renewal, repeat Step 1 and confirm the pin has not changed.

Step 4 - Creating a backup key

If the current key leaks or is lost, you need a key your clients already trust. Generate it now, on a machine other than the web server, and compute its pin:

openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out backup.key
chmod 600 backup.key
openssl pkey -in backup.key -pubout -outform der | openssl dgst -sha256 -binary | openssl enc -base64
47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=

Store backup.key offline (an encrypted USB drive, a vault, a password manager with file support), and ship its pin in every client release next to the current one.

Certbot cannot adopt an existing private key for a certificate it manages. To switch to the backup key you would issue a certificate from a CSR made with it (certbot certonly --csr), which Certbot does not renew automatically. Plan leaf-key rotations as scheduled, manual operations; if that is not acceptable for your team, pin the CA roots instead.

Step 5 - Testing pins with curl

curl can enforce a pin on the server's leaf key with --pinnedpubkey. Several pins are separated by semicolons. Test your current and backup pins:

curl -sS -o /dev/null -w '%{http_code}\n' \
  --pinnedpubkey 'sha256//eHljkwVccz/yoWG1ji21rv4983BsFEDDvbpNNnosPlU=;sha256//47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=' \
  https://your_domain/
200

Now prove that a wrong pin is rejected, so you know the check actually runs:

curl -sS -o /dev/null \
  --pinnedpubkey 'sha256//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' \
  https://your_domain/
curl: (90) SSL: public key does not match pinned public key

Exit code 90 is exactly what a pinned client experiences when the key does not match. Keep this command in your deployment checklist and run it after every certificate change.

Step 6 - Configuring pins in a client

Most HTTP stacks support pinning natively. Always set an expiry or a remote kill switch, so an old app build cannot stay locked out forever.

On Android, declare the pins in res/xml/network_security_config.xml. After the expiration date, the pins stop being enforced and normal CA validation still applies:

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <domain-config>
        <domain includeSubdomains="true">your_domain</domain>
        <pin-set expiration="2027-12-31">
            <pin digest="SHA-256">C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=</pin>
            <pin digest="SHA-256">diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=</pin>
        </pin-set>
    </domain-config>
</network-security-config>

Reference it from the <application> element in AndroidManifest.xml with android:networkSecurityConfig="@xml/network_security_config".

With OkHttp (Android or JVM), the same pins go into a CertificatePinner:

val pinner = CertificatePinner.Builder()
    .add("your_domain", "sha256/C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=")
    .add("your_domain", "sha256/diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=")
    .build()

val client = OkHttpClient.Builder()
    .certificatePinner(pinner)
    .build()

Both check the pins against the validated chain, so root pins work even though the server does not send the root certificate. curl --pinnedpubkey, by contrast, only checks the leaf key.

Step 7 - Rotating a pinned key safely

When you need to change a pinned key, follow this order:

  1. Add the new pin to the client's pin set while keeping the old one, and release that version.
  2. Wait until almost all active clients run a version that contains the new pin. Your analytics or API logs by client version tell you when.
  3. Switch the server to the new key or the new CA, and verify with the curl test from Step 5.
  4. In a later release, remove the old pin and add a fresh backup pin.

Skipping step 2 is the classic pinning outage: the server changes before the clients know the new key.

Alternatives for browser traffic

Browsers can no longer be pinned by websites, but two mechanisms give similar protection:

  • CAA records restrict which CAs may issue for your domain. For Let's Encrypt, add your_domain. CAA 0 issue "letsencrypt.org" at your DNS provider and check it with dig +short CAA your_domain.
  • Certificate Transparency monitoring alerts you when any CA issues a certificate for your domain. Search your domain on https://crt.sh or subscribe to a CT monitoring service, and investigate every certificate you did not request.

Troubleshooting

Clients fail right after a renewal. The key changed. Check reuse_key = True in /etc/letsencrypt/renewal/your_domain.conf, compare the live pin (Step 1) with the shipped pins, and restore the previous key from /etc/letsencrypt/archive/your_domain/ if needed.

Pins pass in curl but fail in the app. The app may be pinning against a key that is not in the chain it validates, for example an intermediate that Let's Encrypt no longer uses. Pin roots or your leaf key, never a specific intermediate.

Pin works on Wi-Fi but fails on some networks. A corporate proxy or antivirus is intercepting TLS with its own CA. Pinning is working as designed; the client should show a clear error instead of silently retrying without the pin.

Conclusion

You generated SPKI pins for a live server, for the Let's Encrypt roots and for an offline backup key, configured Certbot to keep the pinned key across renewals, and verified pins with curl, Android and OkHttp. For most teams, pinning the CA roots combined with CAA records and Certificate Transparency monitoring gives strong protection with little risk. As next steps, add the curl pin check to your deployment pipeline and document the rotation procedure where your on-call team can find it.