Respuesta breve. Los navegadores abandonaron la fijación mediante encabezado (HPKP): Chrome la eliminó en la versión 72 (enero de 2019) y Firefox en la versión 72 (enero de 2020), y Certificate Transparency cubre hoy la mayor parte de lo que HPKP debía detectar. La fijación sobrevive en aplicaciones móviles y clientes de API que se comunican con servidores que controlas. Si fijas, fija el hash SHA-256 de una clave pública (SPKI), no de un certificado; fija claves de CA que vayas a seguir usando (de un certificado intermedio o raíz) en lugar de la clave de tu certificado hoja, que la mayoría de los clientes ACME sustituyen en cada renovación; incluye siempre al menos un pin de respaldo de otra CA o de una clave guardada sin conexión, y usa el mecanismo de la plataforma: la Network Security Configuration (configuración de seguridad de red) de Android, con caducidad, o NSPinnedDomains de iOS. Tanto Google como Apple desaconsejan la fijación salvo que tengas un motivo concreto.
Qué es un pin
La validación ordinaria de certificados acepta cualquier cadena hasta cualquier raíz del almacén de confianza. Un pin añade una condición más: al menos una clave de la cadena validada debe estar en tu lista. La lista contiene hashes del SubjectPublicKeyInfo (SPKI): el identificador de algoritmo y la clave pública, codificados en DER. Calcular el hash de la clave y no del certificado significa que un certificado renovado con la misma clave sigue coincidiendo, y que un pin de una clave de CA coincide con todos los certificados que emite esa CA.
Los pines se calculan a partir de los certificados con OpenSSL. Para la cadena que servía letsencrypt.org el 20 de octubre de 2026 (eclipse-temurin:25.0.4.1_1-jdk-noble, OpenSSL 3.0.13):
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
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
Los cuatro candidatos se comportan de forma muy distinta con el tiempo, y elegir entre ellos es la mayor parte del trabajo. El generador de pines SPKI calcula los mismos valores en el navegador y muestra fragmentos de configuración.
Por qué los navegadores renunciaron: HPKP
HTTP Public Key Pinning (RFC 7469, 2015) permitía a un sitio enviar un encabezado Public-Key-Pins con hashes de claves y un max-age; durante ese tiempo, el navegador rechazaba cualquier cadena que no incluyera una de esas claves. Dos modos de fallo acabaron con él:
- Caídas autoinfligidas. Una clave perdida, un cambio de CA o una errata dejaban fuera a todos los visitantes que volvían hasta que vencía
max-age, sin forma de deshacerlo desde el servidor. - Fijación hostil. Un atacante que controlara brevemente un sitio (o la emisión de sus certificados) podía enviar pines de claves que solo él tenía y convertir así una intrusión breve en una larga denegación de servicio.
La entrada de Chrome sobre la retirada cita una «adopción muy baja» y los «riesgos de denegación de servicio y de fijación hostil» (chromestatus); la compatibilidad se eliminó en Chrome 72 para escritorio y Android, y Firefox hizo lo mismo en la versión 72. Los navegadores aún incluyen una pequeña lista de pines integrada para los dominios de sus propios fabricantes, pero ningún sitio puede añadirse a ella.
Lo que sustituyó a HPKP es la detección en lugar de la prevención. Desde 2018, Chrome y las plataformas de Apple solo aceptan certificados TLS de confianza pública si llevan marcas de tiempo de certificado firmadas (SCT) de logs de Certificate Transparency, así que todo certificado emitido para tu nombre es público y se puede vigilar; los registros CAA indican a las CA cuáles de ellas pueden emitir para tu dominio. Juntos hacen visible en cuestión de horas la emisión indebida contra la que iba HPKP, sin el riesgo de bloqueo.
Qué fijar
| Pin | Sobrevive a | Se rompe cuando |
|---|---|---|
| Clave hoja | Renovaciones que conservan la clave | Cada renovación con una clave nueva, que es el comportamiento predeterminado de certbot |
| Intermedio emisor (YE2) | Renovaciones del certificado hoja y cambios de clave | La CA rota sus intermedios; Let's Encrypt tiene varios en rotación y otros de respaldo desde los que «puede empezar a emitir … en cualquier momento, sin previo aviso» |
| Raíz (ISRG Root X2) | La rotación de intermedios | Cambias de CA o la CA deja de ser de confianza |
| Clave que guardas sin conexión | Cualquier cosa, mientras no se use | Solo si la pierdes |
La fila de la clave hoja merece atención especial, porque es la trampa en la que se cae por defecto. certbot --help all (v5.8.0) describe la opción --reuse-key como desactivada por defecto y dice: «No reutilizar las claves privadas es el comportamiento predeterminado de Certbot». Por tanto, un pin de la clave hoja se rompe en la siguiente renovación, y las renovaciones son cada vez más frecuentes: el CA/Browser Forum limita los certificados TLS a 200 días desde marzo de 2026, a 100 días a partir de marzo de 2027 y a 47 días a partir de marzo de 2029, y el valor predeterminado de Let's Encrypt pasa a 64 días en febrero de 2027.
La fila del intermedio es la sutil. El certificado de letsencrypt.org se emitió desde YE2, pero Let's Encrypt tiene en rotación activa YE1 y YE2 (ECDSA), así como YR1 y YR2 (RSA), con YE3 y YR3 como respaldo (cadenas de confianza). Un conjunto de pines que solo incluya YE2 falla con el siguiente certificado emitido desde YE1.
Un pin de raíz sobrevive a todo eso y solo falla cuando cambias de CA. Eso ocurre más a menudo de lo que se espera: cuando Chrome dejó de confiar en los certificados emitidos por Entrust cuya SCT más antigua fuera posterior al 11 de noviembre de 2024 (Google Security Blog), todos los clientes de Entrust tuvieron que pasarse a otra CA en cuestión de meses. Una aplicación fijada a raíces de Entrust sin pin de respaldo habría necesitado antes una actualización.
El término medio habitual: fija la raíz (o ambas raíces) de tu CA actual, más la raíz de una segunda CA a la que podrías cambiarte y, opcionalmente, una clave que hayas generado y guardes sin conexión para un certificado de emergencia. Con Let's Encrypt como CA actual y Google Trust Services como alternativa, a partir de ca-certificates 20260816 de Debian:
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
El pin de ISRG Root X2 coincide con el último certificado que envía letsencrypt.org (X2 con firma cruzada de X1) porque es la misma clave que la del X2 autofirmado de los almacenes de confianza. Fijar una raíz de otra CA que aún no usas no cuesta nada y es tu vía de salida.
Android: Network Security Configuration
Android 7.0 y posteriores leen los pines de res/xml/network_security_config.xml, al que hace referencia android:networkSecurityConfig en el manifiesto (documentación):
<?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>
Las reglas que importan, según la documentación:
- Se acepta una cadena cuando «contiene al menos una de las claves públicas fijadas»; el único algoritmo de resumen es
SHA-256. - Después de
expiration(yyyy-MM-dd), la fijación deja de aplicarse. Así las instalaciones antiguas siguen funcionando cuando ya te has olvidado de ellas; la documentación también advierte de que «puede permitir a los atacantes eludir tus certificados fijados» en dispositivos con aplicaciones desactualizadas. Elige una fecha que quede aproximadamente un ciclo de versiones después de la fecha en la que vas a actualizar los pines la próxima vez. - Las anclas de confianza declaradas en
<debug-overrides>ignoran los pines cuando la aplicación es depurable, que es como se inspecciona el tráfico durante el desarrollo;overridePins="true"en un elemento<certificates>hace lo mismo en las compilaciones de release y no debería llegar a publicarse. - Android 16 añadió comprobaciones de Certificate Transparency al mismo archivo (
<certificateTransparency enabled="true"/>), y a partir de Android 17 están activadas de forma predeterminada en las aplicaciones orientadas a esa versión, salvo en las conexiones que usan anclas de confianza personalizadas. Para muchas aplicaciones, esa es la protección que buscaban con la fijación.
Las pautas de la propia Google son tajantes: la fijación «no se recomienda para las aplicaciones Android» y, si la usas, incluye «varios pines de respaldo, incluida al menos una clave que esté totalmente bajo tu control, y un período de caducidad suficientemente corto» (seguridad con protocolos de red).
OkHttp
El CertificatePinner de OkHttp funciona en Android y en la JVM, donde no existe Network Security Configuration. La prueba fija letsencrypt.org a ISRG Root X2 con X1 como respaldo y, después, a una clave que ninguna CA ha certificado, que es lo que ve una aplicación cuando el servidor se pasa a una CA no fijada:
// 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());
}
}
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=
El mensaje de fallo enumera los pines de la cadena que validó OkHttp, terminando en el ancla de confianza, y el propio comentario de documentación de OkHttp propone exactamente esto como método de configuración: empezar con un pin roto en una red de confianza y copiar de la excepción los valores esperados. Los pines coinciden con la salida de OpenSSL de arriba. El mismo comentario incluye una advertencia que vale la pena repetir a quien apruebe el cambio: «¡No uses la fijación de certificados sin el visto bueno del administrador de TLS de tu servidor!». Además, CertificatePinner solo se ejecuta después de la validación normal, así que no puede hacer aceptable un certificado autofirmado.
Una trampa de depuración con la que nos topamos: el contenedor no tenía ruta IPv6 y, sin -Djava.net.preferIPv4Stack=true, la segunda llamada informaba de java.net.SocketException: Network is unreachable. OkHttp 5 prueba las direcciones en paralelo («fast fallback»), y el fallo de fijación en la dirección IPv4 acababa como excepción suprimida detrás del error de conexión IPv6; con fastFallback(false), el fallo de fijación era la excepción principal. Cuando la fijación falle en producción, registra también getSuppressed().
iOS y macOS: NSPinnedDomains
Desde iOS 14 y macOS 11, App Transport Security fija las claves declaradas en Info.plist. El artículo de Apple «Identity Pinning» describe dos listas por dominio: NSPinnedCAIdentities (la clave debe aparecer en un certificado intermedio o raíz de la cadena) y NSPinnedLeafIdentities (la clave del propio servidor); cada entrada es un valor SPKI-SHA256-BASE64 calculado con el mismo pipeline de OpenSSL:
<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>
Comprobamos el comportamiento en este equipo (macOS 27.0.1, Apple M4 Max, Swift 6.4): una herramienta de línea de comandos que obtiene https://letsencrypt.org/robots.txt con URLSession, compilada tres veces con swiftc main.swift -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist -Xlinker Info.plist: sin pines, con el pin de X2 de arriba y con la clave no relacionada de la prueba de OkHttp:
HTTP 200
HTTP 200
error: NSURLErrorDomain -1200: A TLS error caused the secure connection to fail.
La aplicación recibe el genérico NSURLErrorSecureConnectionFailed (-1200), no un código específico de la fijación, así que registra con qué conjunto de pines se distribuyó cada versión de la aplicación; sin ese dato, una discrepancia de pines tras un cambio en el servidor parece un fallo de TLS cualquiera en los informes de fallos. NSIncludesSubdomains extiende un pin solo a un nivel de subdominios (math.example.org, no advanced.math.example.org). El consejo de Apple coincide con el de Google: «En la mayoría de los casos, la fijación no es necesaria y debe evitarse» y, si fijas, fija claves de CA y más de una.
Rotación de pines sin bloquear a nadie
Un conjunto de pines es una promesa sobre el futuro, y la aplicación que lo contiene puede pasar un año sin actualizarse. Una rotación que funciona:
- Distribuye el pin nuevo antes de necesitarlo. Añade la raíz de la próxima CA o la próxima clave al conjunto de pines en una versión y espera hasta que las versiones que no lo tienen correspondan a una proporción de usuarios a los que puedas permitirte dejar sin servicio.
- Solo entonces cambia el servidor. Cambia de CA o de clave; los pines antiguos siguen coincidiendo en las versiones antiguas de la aplicación siempre que contengan alguna clave de la nueva cadena.
- Elimina los pines retirados en una versión posterior.
- Mantén una vía de emergencia. Una clave sin conexión que está fijada pero nunca se ha desplegado te permite obtener para ella, en caso de emergencia, un certificado de cualquier CA. Algunas aplicaciones también descargan actualizaciones de pines desde una configuración remota firmada; ese archivo necesita entonces su propia clave de firma, guardada fuera de la infraestructura TLS.
- Define una caducidad en Android y trátala como el plazo para la próxima rotación, no como una formalidad.
- Prueba el fallo. Ejecuta la aplicación a través de un proxy con un certificado de tu almacén de confianza que quede fuera del conjunto de pines, y confirma que rechaza la conexión (falla en cerrado) y muestra un mensaje útil.
Los proxies corporativos de inspección de TLS rompen por diseño las aplicaciones con fijación: sus certificados se encadenan a una raíz empresarial que no está fijada. En Android, las aplicaciones orientadas al nivel de API 24 o superior no confían en las CA instaladas por el usuario salvo que la configuración las añada, y los pines se les siguen aplicando a menos que se establezca overridePins.
Errores frecuentes
- Fijar solo la hoja con un cliente ACME que rota las claves. La siguiente renovación es una caída del servicio.
- Fijar un solo intermedio de una CA que rota varios. Funciona hasta que la CA elige otro.
- No tener pin de respaldo, o tener uno de la misma CA. Cuando esa CA deja de ser de confianza, ambos pines fallan a la vez.
- Fijar el hash de un certificado en lugar del SPKI. Cada renovación lo rompe, incluso con la misma clave.
- Implementar a mano los gestores de confianza (trust managers). Sustituir la validación de la plataforma para «añadir fijación» a menudo elimina por accidente las comprobaciones del nombre de host o de la cadena. Usa Network Security Configuration,
CertificatePinneroNSPinnedDomains, que fijan sobre la validación normal. - Esperar que los pines protejan frente a un dispositivo comprometido. En un dispositivo con acceso root o con jailbreak, el usuario (o el malware) puede parchear la aplicación para quitar la fijación. La fijación protege a los usuarios frente a la red, no a tu API frente a tus usuarios.
Fuentes
- RFC 7469: Public Key Pinning Extension for HTTP (extensión de fijación de claves públicas para HTTP) y la entrada de Chrome sobre la retirada
- Android: Configuración de seguridad de red (pines, caducidad, anulaciones, Certificate Transparency) y Seguridad con protocolos de red
- Apple: Identity Pinning (fijación de identidad): cómo configurar los certificados de servidor para tu app y política de Certificate Transparency
- Código fuente de
CertificatePinnerde OkHttp y su comentario de documentación - Let's Encrypt: Cadenas de confianza y Reducción de la vida útil de los certificados a 45 días
- Baseline Requirements del CA/Browser Forum 2.3.1, §6.3.2 (períodos de validez)
- Google Security Blog: retirada de la confianza en los certificados de Entrust (junio de 2024)