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
- Certificate Pinning Concepts
- Pin Formats and Generation
- HPKP Header Configuration
- Nginx HPKP Implementation
- Apache HPKP Implementation
- Backup Pins and Strategy
- TOFU (Trust On First Use)
- Testing and Monitoring
- Troubleshooting
- Conclusion
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 subdomainsreport-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:
- Client receives pin
- Client stores pin with expiration
- 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):
- Open DevTools (F12)
- Security tab
- 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.


