El certificate pinning sigue siendo uno de los controles peor implementados en aplicaciones móviles. O no existe. O existe pero está anclado al certificado hoja y se rompe en cada renovación. O existe sin pin de respaldo y provoca un incidente en producción la primera vez que infraestructura rota las claves sin avisar al equipo móvil.
Este artículo cubre la implementación correcta en iOS 18 y Android 15 con las APIs que Apple y Google recomiendan en 2025, una estrategia de rotación que no tira el servicio, y los controles de detección de bypass que cierran el flanco cuando el dispositivo puede estar comprometido.
Qué pinear: certificado hoja vs clave pública (SPKI)
Hay dos formas de anclar la identidad del servidor: pinear el certificado completo, o pinear el hash de la clave pública (Subject Public Key Info, SPKI). El primero es más fácil de implementar y obliga a actualizar la app cada vez que renueva el certificado, que con Let’s Encrypt son 90 días y con un certificado comercial suele ser un año.
Pinea siempre la clave pública (SPKI), no el certificado. La clave pública puede sobrevivir años a través de varias renovaciones, así que no necesitas publicar una nueva versión cada vez que renueva el cert.
Extraer el hash SPKI antes de escribir una línea de código
El hash que vas a meter en la app se saca del certificado en producción con una cadena de OpenSSL: extraes la representación DER de la clave pública y la pasas por SHA-256.
# Extraer el hash SPKI del certificado activo en producción
openssl s_client -connect api.tuapp.com:443
-servername api.tuapp.com < /dev/null 2>/dev/null
| openssl x509 -pubkey -noout
| openssl pkey -pubin -outform DER
| openssl dgst -sha256 -binary
| base64
# Para el pin de respaldo: extraer de la CSR del certificado siguiente
openssl req -in siguiente-certificado.csr -pubkey -noout
| openssl pkey -pubin -outform DER
| openssl dgst -sha256 -binary
| base64Code language: Bash (bash)
Los dos hashes que salen son los que van a la configuración de iOS y Android. El segundo (del certificado que aún no has desplegado) es el pin de respaldo y es lo que te permite rotar sin dejar a los usuarios sin servicio.
iOS 18: implementación con URLSession y la API moderna de SecTrust
En iOS 15 Apple deprecó SecTrustGetCertificateAtIndex a favor de SecTrustCopyCertificateChain, que devuelve la cadena completa como un tipo seguro de Swift en lugar de un opcional sin garantías de retención. La implementación correcta para iOS 15+ valida primero la cadena PKI estándar (no la sustituye) y solo después comprueba el hash SPKI.
import CryptoKit
import Security
// Cabecera ASN.1 para clave pública EC P-256 (tipo estándar en TLS moderno)
private let ecP256Header = Data([
0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce,
0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d,
0x03, 0x01, 0x07, 0x03, 0x42, 0x00
])
private func spkiHash(for cert: SecCertificate) -> String? {
guard
let key = SecCertificateCopyKey(cert),
let keyData = SecKeyCopyExternalRepresentation(key, nil) as? Data
else { return nil }
let hash = SHA256.hash(data: ecP256Header + keyData)
return Data(hash).base64EncodedString()
}
final class PinningDelegate: NSObject, URLSessionDelegate {
private let pinnedHashes: Set<String> = [
"YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=", // pin activo
"Vjs8r4z+80wjNcr1YKepWQkMm1KFkb/9oOSYKzEYQAU=" // pin de respaldo
]
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard
challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = challenge.protectionSpace.serverTrust
else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// Paso 1: validar la cadena de confianza estándar
var error: CFError?
guard SecTrustEvaluateWithError(trust, &error) else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// Paso 2: SecTrustCopyCertificateChain reemplaza el API deprecado en iOS 15+
guard
let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
let leaf = chain.first,
let hash = spkiHash(for: leaf)
else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
if pinnedHashes.contains(hash) {
completionHandler(.useCredential, URLCredential(trust: trust))
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
}Code language: Swift (swift)
Android 15: Network Security Config y OkHttp
En Android, lo más mantenible es el fichero network_security_config.xml: el sistema operativo lo aplica automáticamente a todas las conexiones de la app sin que tengas que tocar el código de red. Desde Android 9 (API 28) acepta el campo expiration, que avisa al usuario cuando el pin ha caducado en lugar de fallar en silencio.
<!-- res/xml/network_security_config.xml -->
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="true">api.tuapp.com</domain>
<pin-set expiration="2027-06-01">
<!-- Pin activo: SPKI SHA-256 del certificado en producción -->
<pin digest="SHA-256">YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=</pin>
<!-- Pin de respaldo: SPKI del certificado siguiente en rotación -->
<pin digest="SHA-256">Vjs8r4z+80wjNcr1YKepWQkMm1KFkb/9oOSYKzEYQAU=</pin>
</pin-set>
</domain-config>
</network-security-config>Code language: HTML, XML (xml)
Si tu app va con OkHttp (la mayoría con Retrofit o cualquier cliente HTTP moderno), CertificatePinner aplica pinning SPKI de forma nativa. Es la opción correcta cuando la configuración de red tiene que ser dinámica o cuando compartes el cliente HTTP con un SDK de terceros que ignora el Network Security Config.
// build.gradle.kts: implementation("com.squareup.okhttp3:okhttp:4.12.0")
val pinner = CertificatePinner.Builder()
.add("api.tuapp.com", "sha256/YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=")
.add("api.tuapp.com", "sha256/Vjs8r4z+80wjNcr1YKepWQkMm1KFkb/9oOSYKzEYQAU=")
.build()
val httpClient = OkHttpClient.Builder()
.certificatePinner(pinner)
.connectTimeout(30, TimeUnit.SECONDS)
.build()Code language: Kotlin (kotlin)
Estrategia de rotación sin incidentes de producción
La rotación es lo que más a menudo convierte el certificate pinning en un dolor de cabeza operativo. Si infraestructura rota sin coordinar con el equipo móvil, los usuarios con versiones antiguas se quedan sin red hasta que actualizan, y en apps con tasa de actualización baja eso afecta a una parte significativa de la base activa durante días.
- Con al menos 60 días de antelación: generar el nuevo par de claves y extraer el hash SPKI del certificado siguiente. Añadirlo como pin de respaldo en la versión actual de la app y publicarla en las tiendas.
- Esperar a que la adopción de la nueva versión supere el 95% de los usuarios activos antes de continuar. No fuerces la fecha por calendarios de infraestructura.
- Rotar el certificado en producción. Los usuarios con la versión nueva ya tienen el pin de respaldo y no notan nada. Los usuarios con versiones muy antiguas se quedan sin servicio, pero son una minoría aceptable si la espera fue suficiente.
- Publicar una versión que convierte el pin de respaldo en pin activo y añade un nuevo pin de respaldo para la próxima rotación. El ciclo se reinicia.
Detección de bypass: Frida y repackaging
El certificate pinning no aguanta a un atacante con acceso físico al dispositivo y tiempo. Frida u objection parchean la implementación en runtime y anulan el pinning sin tocar el binario. La defensa en profundidad consiste en añadir comprobaciones de integridad del proceso que suban el coste del bypass y detecten cuándo la app corre bajo instrumentación dinámica.
// Android: señales de presencia de Frida en el proceso
object RuntimeIntegrityCheck {
// Frida Server escucha por defecto en el puerto 27042 (0x69A2 en hex)
private const val FRIDA_PORT_HEX = "69A2"
fun hasFridaIndicators(): Boolean {
val knownPaths = listOf(
"/data/local/tmp/frida-server",
"/data/local/tmp/re.frida.server",
"/system/lib/libfrida-gadget.so",
"/system/lib64/libfrida-gadget.so"
)
if (knownPaths.any { java.io.File(it).exists() }) return true
// Comprobar si el puerto de Frida está abierto en /proc/net/tcp
return try {
java.io.File("/proc/net/tcp").readLines()
.any { line -> line.contains(FRIDA_PORT_HEX, ignoreCase = true) }
} catch (e: Exception) {
false
}
}
}
// Llamar antes de inicializar el cliente HTTP
if (RuntimeIntegrityCheck.hasFridaIndicators()) {
// No mostrar error descriptivo: simplemente degradar o cerrar
finish()
return
}Code language: Kotlin (kotlin)
En iOS, el equivalente es comprobar si el proceso está siendo trazado mediante sysctl antes de abrir cualquier conexión. El flag P_TRACED en la kinfo_proc del proceso actual lo activan los debuggers y la mayoría de instrumentos de hooking en cuanto se adjuntan.
Lo que el pinning no resuelve por sí solo
El certificate pinning protege el canal de transporte entre la app y el servidor. No cubre todos los vectores que importan en el modelo de amenazas de una aplicación móvil:
- Repackaging con pin modificado: el atacante redistribuye la app con el pinning eliminado del binario. La defensa complementaria es la verificación de integridad del binario y Google Play Integrity API o el equivalente en iOS 18.
- Certificados raíz instalados por MDM corporativo: en dispositivos gestionados, la organización puede instalar CAs personalizadas que el SO confía implícitamente. Aquí el pinning sí protege porque no depende de la cadena de confianza del sistema.
- Vulnerabilidades en la lógica de negocio: el pinning no protege contra ataques que no necesitan interceptar tráfico, como sacar tokens del almacenamiento local o explotar endpoints vulnerables directamente.
Si estáis revisando el modelo de amenazas completo de vuestra aplicación móvil y queréis ver tanto la implementación de certificate pinning como los controles de integridad en runtime, hablamos y vemos qué tiene sentido en vuestro contexto.
