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.

The Inside Job: Frida Server & Frida Gadget

No matter how robust your client-side cryptography is, it operates within an environment controlled by the user. If an attacker gains full administrative access to that environment (root on Android, jailbreak on iOS), they can run Frida Server.

Frida operates by injecting a dynamic JavaScript engine into the memory space of your running process. Once inside, it doesn’t care about SSL pins or certificates; it can hook directly into the network libraries (like NSURLSession on iOS or OkHttp on Android) or even lower-level system socket APIs.

But jailbreaks and roots are becoming rarer, and many OS detection systems flag them. The real harsh reality is Frida Gadget.

Frida Gadget is a shared library that can be packaged directly into your application’s binary. An attacker extracts your APK or decrypted IPA, embeds Frida Gadget, patches the binary’s load commands (using tools like optool or patchapk) to load the gadget on startup, and repackages/resigns the app. This modified app runs on any ordinary, retail device. The moment it launches, the gadget fires up, opens a debugger port, and executes the attacker’s scripts—granting them full visibility into memory.

By hooking low-level TLS/SSL functions like SSL_write, an attacker can intercept every outbound request and inbound response in plaintext, right before encryption or right after decryption:

Frida Hook (BoringSSL)

// Intercepting BoringSSL/OpenSSL encryption boundary to see plaintext traffic
const ssl_write_ptr = Module.findExportByName("libboringssl.dylib", "SSL_write") 
  || Module.findExportByName("libssl.so", "SSL_write");
 
if (ssl_write_ptr) {
  Interceptor.attach(ssl_write_ptr, {
    onEnter(args) {
      // args[1] is the buffer containing the plaintext request data
      const buf = args[1];
      const len = args[2].toInt32();
      const plaintext = Memory.readUtf8String(buf, len);
      
      console.log(`[+] Intercepted Plaintext Payload (${len} bytes):`);
      console.log(plaintext);
    }
  });
}

This reveals your entire request-response cycles, headers, URL parameters, authentication structures, and custom encryption algorithms. Once these are laid bare, the app’s secure facade vanishes, allowing the attacker to easily script API clients and bypass the mobile app entirely.

Edge Defenses: The Backend Reality Check (WAF, Akamai, Cloudflare)

If a determined attacker can always bypass client-side protections using tools like Frida, then how do we protect our backend from automated scraping, credential stuffing, API abuse, and DDoS attacks?

The answer is simple: treat the mobile client as completely untrusted and hostile. Shift your ultimate defenses to the network edge by utilizing robust Web Application Firewalls (WAF) and bot management suites like Akamai, Cloudflare, or AWS WAF.

Bot Mitigation & Behavioral Profiling: Modern edge platforms don’t just look at IP rates. They analyze the TCP/IP connection fingerprint (e.g., JA3 or JA4 fingerprints), TLS negotiation options, and HTTP/2 settings. Scripts using libraries like Python’s requests or Go’s net/http leave distinct cryptographic fingerprints that do not match the expected signatures of actual iOS or Android systems, allowing WAFs to block them instantly.

Rate Limiting & DDoS Shielding: Enforce strict request thresholds on your API endpoints at the CDN edge. A WAF can absorb massive volumetric DDoS attacks, distribute the load across global scrubbing centers, and throttle high-frequency automated scraping before a single malicious request reaches your origin servers.

Dynamic Challenges & SDK Integrations: When requests look suspicious, CDNs can issue managed challenges. For native mobile applications where traditional JavaScript challenges fail, enterprise WAFs (such as Cloudflare Mobile SDK or Akamai Bot Manager) provide mobile SDK integrations to dynamically generate device telemetry and solve challenges natively.

API Shielding: Configure strict API schemas at the edge. A WAF can validate that incoming JSON payloads strictly match your API definitions (fields, types, sizes) and instantly drop malformed request bodies, protecting your backend code from buffer overflows and unexpected exceptions.

By combining hardware-backed client identity with edge-level traffic inspection, you create a defense-in-depth architecture. Even if an attacker extracts your API design on a Frida-hooked device, they cannot easily automate attacks against your servers at scale.

🏁 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 🚀