byprofile_photo

SSL Pinning Is Just The First Floor

As mobile developers, it is easy to look at application security as a series of boxes waiting to be checked. We read guidelines, run static analysis tools, and fix vulnerabilities as they pop up in our reports. But in the rush to build features and hit deadlines, it is just as easy to focus entirely on isolated fixes while missing the broader picture of how an attacker actually interacts with our code. We implement a specific defense, assume the problem is solved, and move on—without realizing we’ve left another door entirely wide open.

Take network security, for example. You add SSL pinning because every mobile security checklist tells you to. Suddenly, Charles Proxy or Proxyman starts throwing handshake errors. It feels good. It feels like you’ve locked the front door, so you ship it and move on to the next feature.

But if someone jailbreaks a test device and drops Frida into your app, they can read every single request and response in plaintext anyway.

It’s not because your pinning implementation is broken. It’s just that pinning was designed to protect the wire, not the device itself. You didn’t do anything wrong; you just stopped one floor too early. Let’s walk up together.

How Proxies Watch the Wire

To understand why pinning matters, we first have to look at how tools like Charles Proxy or Proxyman view our traffic in the first place.

Normally, when your app wants to talk to a server, it routes traffic directly through the internet. When you open a debugging tool, it spins up a local proxy server on your computer. By changing your phone’s Wi-Fi settings to point to your computer’s IP address, you are intentionally routing all of your app’s traffic directly through that tool.

For plain HTTP traffic, reading the data is simple. The proxy acts like a postal worker opening an unsealed envelope, logging the text to your screen, and passing it along.

But for secure HTTPS traffic, things get interesting. The proxy can’t read encrypted data without the server’s private key, so it performs a constructive Man-in-the-Middle (MITM) architecture:

The Trust Anchor: You install the proxy tool’s custom Root Certificate onto your test device and manually mark it as “Trusted” in your system settings.

The Split Handshake: When your app tries to connect to your secure server, the proxy tool intercepts the request. It connects to the real server on your behalf to establish a secure outbound connection. Simultaneously, it generates a fake, dynamic certificate for your domain on the fly, signs it with its own Root Certificate, and hands it back to your app.

The Decryption Point: Because you told the device’s OS to trust the proxy’s root certificate, the app completes the handshake. Now, when your app sends data, it encrypts it using the proxy’s key. The proxy decrypts it, logs the plaintext on your monitor, re-encrypts it with the real server’s key, and sends it out to the internet.

This handshake switch is exactly why these tools are so incredibly powerful for daily debugging.

What Pinning Actually Buys Us

This entire proxy mechanism relies on a single vulnerability: the app blindly trusting whatever the device’s operating system root store tells it to trust. If a user can force the OS to trust a malicious root certificate, the secure tunnel is broken.

Pinning elegantly narrows that down. It teaches your app to bypass the device’s root store for specific domains, saying, “I don’t care if the operating system trusts this certificate; I am checking the public key myself against a copy I have hardcoded inside my own binary.” When Proxyman tries to hand your app its dynamically generated fake certificate, the pin check fails immediately. Your app realizes someone is sitting in the middle of the conversation and gracefully drops the connection before sending a single byte of data.

But notice where this defense is looking: it’s looking outward. It assumed the person trying to watch the traffic is standing on the wire between your app and your server—not sitting comfortably right inside the app’s own memory space.

The Shift in Perspective

The moment a user roots or jailbreaks their phone, the entire environment changes.

A tool like Frida doesn’t waste time trying to forge a certificate to fool your pin check on the wire. Instead, it gently steps inside the running application’s memory space and alters the compiled function responsible for checking the pin, making it always return a success state.

It doesn’t pick your cryptographic lock; it just walks around the door entirely. Pinning simply isn’t built to survive runtime code modification, and realizing that is where real mobile security begins.

mTLS — Starting a Mutual Conversation

Standard TLS is a one-way street: your app checks if it can trust the server. Mutual TLS (mTLS) turns that into a real conversation, requiring the server to ask, “And who are you?” Your app must present its own unique certificate and prove it holds the matching private key before any data is exchanged.

This helps us solve two major headaches:

The Reality Check: The Backend Operational Nightmare

As beautiful as mTLS is in theory, it asks for a lot of love on the backend. If you have millions of active users, you are suddenly managing millions of unique client certificates.

If you rely on traditional revocation checks, your databases will feel the weight very quickly. To keep things peaceful for your infrastructure, you usually have to look into hyper-optimized validation or short-lived certificates.

And we have to remember: if Frida is already running in our app’s memory, it can still peek at the data before it gets encrypted or after it gets decrypted by the network layer. The wire might be secure, but the room it’s being read in is shared.

Keeping Keys Safely in Hardware

A common instinct is to generate a client key on the server and send it down to the app, or store it in a local database. But if that private key ever travels across the network or sits in standard storage, the magic of mTLS starts to fade.

Instead, we let the device take care of it from the ground up:

What it Looks Like

Swift

// iOS — Keeping the key safe inside the Secure Enclave
let access = SecAccessControlCreateWithFlags(
  nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
  .privateKeyUsage, nil
)
 
let attributes: [String: Any] = [
  kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
  kSecAttrKeySizeInBits as String: 256,
  kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
  kSecPrivateKeyAttrs as String: [
    kSecAttrIsPermanent as String: true,
    kSecAttrAccessControl as String: access as Any
  ]
]
 
let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, nil)

Kotlin / Android

// Android — Storing the key within the Keystore/StrongBox
val keyPairGenerator = KeyPairGenerator.getInstance(
  KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore"
)
 
keyPairGenerator.initialize(
  KeyGenParameterSpec.Builder("client_auth_key", KeyProperties.PURPOSE_SIGN)
    .setDigests(KeyProperties.DIGEST_SHA256)
    .setIsStrongBoxBacked(true)
    .build()
)
 
val keyPair = keyPairGenerator.generateKeyPair()

A Small Design Note

The Secure Enclave strictly requires Elliptic Curve (EC P-256) keys, not traditional RSA. It’s worth checking in with your backend team early to ensure your server stack is comfortable talking in elliptic curves.

Navigating the Android Landscape

Take a close look at the Android configuration: .setIsStrongBoxBacked(true). StrongBox is wonderful, but it relies on specialized hardware chips (like the Titan M) that aren’t present in every device.

If you make StrongBox a strict requirement, your app will gently crash or refuse to work for users on older or more budget-friendly Android devices. In production, a softer touch works best: try utilizing StrongBox first, catch the exception if it’s missing, and gracefully fall back to the standard hardware-backed Trusted Execution Environment (TEE).

The Trust Architecture: Signing the CSR Without Secrets

When we build this hardware flow, a natural cryptographic question comes up: If we are bundling the public key into a Certificate Signing Request (CSR) and sending it over the network to our backend, can’t an attacker intercept the payload, modify the details, or send their own public key?

This is where the mathematical magic of asymmetric keys protects us.

When the CSR is built on the device, it wraps the raw public key along with basic metadata and creates an internal digital signature using the corresponding hidden private key. This mechanism is called Proof of Possession.

When your backend Certificate Authority receives the request, it uses the public key inside the envelope to verify that signature. This introduces a perfect logical trap for an attacker:

But what if an attacker runs a script on their own laptop, generates a clean keypair there, signs a CSR with their own private key, and sends it to your endpoint while pretending to be your app?

To solve that final edge case, the initialization phase cannot happen in a vacuum. The CSR payload must be tightly coupled with the Device Attestation layer.

Device Attestation — Checking the Ground We Stand On

mTLS is great at telling us which key is signing a request, but it can’t tell us if the phone holding that key is healthy. A compromised device can still use a perfectly valid, hardware-bound key because the hooking tools just ride along next to it inside the process.

This is where Apple’s App Attest and Google’s Play Integrity come in to lend a hand. Instead of your app trying to prove its own innocence, the operating system itself vouches for it.

When your app requests its initial certificate via the CSR flow, it simultaneously asks the OS to provide a cryptographically signed attestation token. This token evaluates the device’s integrity, checks if the application binary matches your official store signature, and links directly to the public key you generated. Your server validates this entire chain back to the vendor’s root before it issues an identity certificate. This proves the public key belongs to a genuine instance of your app running on real, untampered smartphone hardware, completely ignoring fake requests coming from a script or a laptop.

Request Body Signing: Protecting Everyday Traffic

Once this trust is established and you move past provisioning into daily production, you can use that same hardware-backed private key to protect day-to-day data streams through Request Body Signing.

Before sending a standard JSON payload to your server, the app bundles the body text, a current timestamp, and a monotonic counter (nonce) into a single string. It passes this data to the secure hardware chip to be signed.

When your backend checks this signature using the stored public key, it guarantees two things:

Balances in Performance & User Experience

Because full attestation requires your user’s device to talk directly to Apple or Google’s servers to sign a token, it is a naturally heavy operation.

And yes, there is always an ongoing arms race between root-hiding tools and OS detection patches. It’s a shifting landscape, but implementing this raises the effort required for an exploit from a casual script to a highly dedicated, targeted project.

🏁 Final Thoughts

When we step back and look at the whole picture, each layer simply tries to answer a different question:

Pinning — Is our connection safe from basic outside eyes?

mTLS — Can we trust the cryptographic identity of this device?

Attestation — Is the digital environment running our code healthy right now?

Server Monitoring — Does the behavior of this traffic feel human and right?

In mobile development, there is no such thing as an absolute, unbreakable lock. If someone has physical control of a device, plenty of time, and a clear goal, they can eventually look inside.

Our goal isn’t to build a perfect fortress; it’s simply to build thoughtfully. By layering these defenses, we make reverse engineering expensive and complex, creating a safe, respectful space for our genuine users to enjoy.

→ Found this helpful? Let me know or share it with a fellow mobile developer 🚀