Certificate Pinning Configuration

Certificate pinning prevents man-in-the-middle attacks by restricting acceptance of only specific certificates or public keys. This guide covers HTTP Public Key Pinning (HPKP), pin formats, backup pins, Trust On First Use (TOFU) strategies, and implementation risks.

Table of Contents

Prerequisites

Before implementing certificate pinning, ensure you have:

  • Web server (Nginx, Apache)
  • Access to certificate files
  • OpenSSL installed
  • Root or sudo access
  • Understanding of PKI concepts
  • Test browsers and devices

Certificate Pinning Concepts

Certificate pinning pins specific certificates, public keys, or CA certificates to prevent certificate compromise or substitution attacks.

Types of pinning:

Public Key Pinning: Pin the public key from the certificate

  • More flexible (survives certificate renewal with same key)
  • Preferred for production

Certificate Pinning: Pin the entire certificate

  • Most restrictive (requires new pin on renewal)
  • Useful for development/testing

CA Pinning: Pin the CA certificate

  • Allows any certificate from the CA
  • Less restrictive

Pinning process:

Client                              Server
   |                                  |
   +-- Request https://example.com -->
   |                                  |
   |<-- Certificate delivered --------+
   |
   +-- Check: Is public key pinned? --+
   |      YES -> Continue
   |      NO  -> Reject connection
   |
   +-- Proceed with TLS handshake ----->

Pin Formats and Generation

Generate pin hashes from certificates or keys.

Extract public key and generate SHA256 pin:

# From certificate
openssl x509 -in cert.pem -pubkey -noout | \
  openssl pkey -pubin -outform DER | \
  openssl dgst -sha256 -binary | \
  openssl enc -base64

Complete pin generation script:

#!/bin/bash

openssl x509 -in "$1" -pubkey -noout | \
  openssl pkey -pubin -outform DER | \
  openssl dgst -sha256 -binary | \
  openssl enc -base64

Run:

./generate-pin.sh cert.pem
# Output: pin-sha256="xxxxxx...="

Generate pins for multiple certificates:

for cert in certs/*.pem; do
  echo "Pin for $cert:"
  openssl x509 -in "$cert" -pubkey -noout | \
    openssl pkey -pubin -outform DER | \
    openssl dgst -sha256 -binary | \
    openssl enc -base64
  echo
done

Pin format for HPKP header:

pin-sha256="base64-encoded-hash"

Example pins:

pin-sha256="YLh1dUR9LceIOaxziAiAtVxwOB0+rMq0IstNm11qwJ4="
pin-sha256="C9CWiITrSEf0jzBOoVmbH1xntVcWQeFLCcqmeQARBmI="

HPKP Header Configuration

HTTP Public Key Pinning transmits pins via response header.

HPKP header format:

Public-Key-Pins: pin-sha256="pin1"; pin-sha256="pin2"; max-age=seconds; includeSubDomains; report-uri="url"

Parameters:

  • pin-sha256: Public key pin(s)
  • max-age: Seconds to cache pins (max 18 months = 63072000)
  • includeSubDomains: Apply to subdomains
  • report-uri: Where to report pin violations

Example:

Public-Key-Pins: pin-sha256="YLh1dUR9LceIOaxziAiAtVxwOB0+rMq0IstNm11qwJ4="; pin-sha256="C9CWiITrSEf0jzBOoVmbH1xntVcWQeFLCcqmeQARBmI="; max-age=2592000; includeSubDomains; report-uri="https://example.com/hpkp-report"

Nginx HPKP Implementation

Configure HPKP headers in Nginx:

server {
    listen 443 ssl http2;
    server_name example.com;
    
    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    
    # HPKP header
    add_header Public-Key-Pins 'pin-sha256="YLh1dUR9LceIOaxziAiAtVxwOB0+rMq0IstNm11qwJ4="; pin-sha256="C9CWiITrSEf0jzBOoVmbH1xntVcWQeFLCcqmeQARBmI="; max-age=2592000; includeSubDomains' always;
    
    # Report-only mode (for testing)
    add_header Public-Key-Pins-Report-Only 'pin-sha256="YLh1dUR9LceIOaxziAiAtVxwOB0+rMq0IstNm11qwJ4="; pin-sha256="C9CWiITrSEf0jzBOoVmbH1xntVcWQeFLCcqmeQARBmI="; max-age=2592000; includeSubDomains; report-uri="https://example.com/hpkp-report"' always;
}

Test with curl:

curl -i https://example.com | grep Public-Key-Pins

Apache HPKP Implementation

Configure HPKP in Apache VirtualHost:

<VirtualHost *:443>
    ServerName example.com
    
    SSLEngine on
    SSLCertificateFile /etc/letsencrypt/live/example.com/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem
    
    # Enable mod_headers
    <IfModule mod_headers.c>
        # HPKP enforcement
        Header always set Public-Key-Pins "pin-sha256=\"YLh1dUR9LceIOaxziAiAtVxwOB0+rMq0IstNm11qwJ4=\"; pin-sha256=\"C9CWiITrSEf0jzBOoVmbH1xntVcWQeFLCcqmeQARBmI=\"; max-age=2592000; includeSubDomains"
        
        # Or report-only mode
        Header always set Public-Key-Pins-Report-Only "pin-sha256=\"YLh1dUR9LceIOaxziAiAtVxwOB0+rMq0IstNm11qwJ4=\"; pin-sha256=\"C9CWiITrSEf0jzBOoVmbH1xntVcWQeFLCcqmeQARBmI=\"; max-age=2592000; includeSubDomains; report-uri=\"https://example.com/hpkp-report\""
    </IfModule>
</VirtualHost>

Enable mod_headers:

sudo a2enmod headers
sudo systemctl reload apache2

Backup Pins and Strategy

Always include backup pins to recover from certificate compromise or renewal failures.

Best practice pinning strategy:

Primary pin (current certificate public key)
Backup pin 1 (next certificate public key - prepare this before renewal)
Backup pin 2 (CA intermediate public key - allows flexibility)

Example with three pins:

pin-sha256="YLh1dUR9LceIOaxziAiAtVxwOB0+rMq0IstNm11qwJ4="  # Current certificate
pin-sha256="C9CWiITrSEf0jzBOoVmbH1xntVcWQeFLCcqmeQARBmI="  # Backup certificate
pin-sha256="AxLMg0Y5PbfPaVIvPhGEz0kXQkKEzTbU5hiFN2hnH8c="  # Let's Encrypt Intermediate X3

Prepare before certificate renewal:

#!/bin/bash
# prepare-backup-pins.sh

CERT_FILE=$1

# Current pin
CURRENT_PIN=$(openssl x509 -in "$CERT_FILE" -pubkey -noout | \
  openssl pkey -pubin -outform DER | \
  openssl dgst -sha256 -binary | \
  openssl enc -base64)

# Generate new certificate with same key
# (or obtain next certificate and extract its pin)

# Get Let's Encrypt Intermediate pin
LE_CERT_FILE="lets-encrypt-x3.pem"
BACKUP_PIN=$(openssl x509 -in "$LE_CERT_FILE" -pubkey -noout | \
  openssl pkey -pubin -outform DER | \
  openssl dgst -sha256 -binary | \
  openssl enc -base64)

echo "Current pin-sha256=\"$CURRENT_PIN\""
echo "Backup pin-sha256=\"$BACKUP_PIN\""

Certificate renewal workflow with pinning:

# 1. Generate new certificate
certbot certonly --dns-cloudflare -d example.com

# 2. Extract new pin
NEW_PIN=$(openssl x509 -in /etc/letsencrypt/live/example.com/cert.pem \
  -pubkey -noout | openssl pkey -pubin -outform DER | \
  openssl dgst -sha256 -binary | openssl enc -base64)

# 3. Update server config with both old and new pins
# Public-Key-Pins: pin-sha256="$OLD_PIN"; pin-sha256="$NEW_PIN"; ...

# 4. Reload server
systemctl reload nginx

# 5. After successful rollout, remove old pin from future renewals

TOFU (Trust On First Use)

TOFU establishes trust by remembering the first pin encountered and validating subsequent connections.

HPKP implements TOFU through max-age:

max-age=2592000  # 30 days - remember this pin

On first connection:

  1. Client receives pin
  2. Client stores pin with expiration
  3. Future connections validate against stored pin

Risks of TOFU:

  • First connection vulnerability (no trust established)
  • Pinning for 30+ days locks in certificate

Mitigation strategies:

# Short max-age for gradual rollout
max-age=300  # 5 minutes - test only

# Longer max-age after validation
max-age=2592000  # 30 days - production

# Start with report-only mode
Public-Key-Pins-Report-Only: ...

# Switch to enforcement after testing
Public-Key-Pins: ...

Testing and Monitoring

Validate pinning implementation and monitor violations.

Test HPKP header:

curl -i https://example.com | grep Public-Key-Pins

Browser testing (Chrome):

  1. Open DevTools (F12)
  2. Security tab
  3. Check "Public-Key-Pins" in response headers

Report-only monitoring:

# Create report receiver endpoint
curl -X POST https://example.com/hpkp-report \
  -H "Content-Type: application/json" \
  -d '{
    "date-time": "2024-01-15T10:30:00Z",
    "hostname": "example.com",
    "port": 443,
    "effective-expiration-date": "2024-02-15T10:30:00Z",
    "include-subdomains": true,
    "noted-hostname": "example.com",
    "served-certificate-chain": [...],
    "validated-certificate-chain": [...],
    "known-pins": [...]
  }'

Log and analyze reports:

#!/usr/bin/env python3
from flask import Flask, request, jsonify
import json

app = Flask(__name__)

@app.route('/hpkp-report', methods=['POST'])
def hpkp_report():
    report = request.get_json()
    
    # Log violation
    with open('hpkp-violations.log', 'a') as f:
        json.dump(report, f)
        f.write('\n')
    
    print(f"HPKP violation from {report['hostname']}")
    
    return jsonify({'status': 'received'}), 204

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

Troubleshooting

Pin mismatch errors:

# Verify pin matches certificate
EXPECTED_PIN="YLh1dUR9LceIOaxziAiAtVxwOB0+rMq0IstNm11qwJ4="

ACTUAL_PIN=$(openssl x509 -in cert.pem -pubkey -noout | \
  openssl pkey -pubin -outform DER | \
  openssl dgst -sha256 -binary | \
  openssl enc -base64)

[ "$EXPECTED_PIN" = "$ACTUAL_PIN" ] && echo "Match" || echo "Mismatch"

Certificate renewal locked out:

# If certificate renewed but pin not updated, clients locked out
# Must wait for max-age to expire or restore previous certificate

# Prevention: update pins before renewal

Testing in report-only mode:

# Monitor violations without enforcing
Public-Key-Pins-Report-Only: pin-sha256="..."; report-uri="..."

# After confirmation, switch to enforcement
Public-Key-Pins: pin-sha256="..."; includeSubDomains

Conclusion

Certificate pinning provides defense-in-depth against certificate compromise and unauthorized certificate authorities. This guide covered HPKP header configuration in Nginx and Apache, pin generation, backup pin strategies, TOFU implementation, and testing. While powerful, pinning carries risk of accidental lockout. Start with report-only mode, maintain backup pins, plan certificate renewals carefully, and monitor violation reports. Certificate pinning excels for protecting critical authentication endpoints and preventing man-in-the-middle attacks at the transport layer.