Secure iOS app-to-backend communication β part 1: Request signing and quantum-secure TLS
Imagine this scenario: someone intercepts today's encrypted traffic between your app and the backend. They can't read it - TLS is working correctly. So they simply... save that data to disk. In 10β15 years, when quantum computers become powerful enough, they decrypt everything with Shor's algorithm in one go.
This isn't science fiction. It's a strategy known as "harvest now, decrypt later" - and intelligence agencies have been using it for years.
This article is the first of three that will show you how to secure your backend and iOS app communication to make it resistant to future attacks.
Today we'll focus on two things:
- Request signing - how to prove to the backend that a request truly comes from your app and hasn't been tampered with,
- Quantum-secure TLS - how to use the new iOS 26 API to protect against future quantum attacks.
Part 1: Request signing - your digital signature
TLS vs JWT vs request signing - what does each protect against?
Before we dive into implementation, let's get our bearings. These three mechanisms often appear together, but each protects against something different:
| Mechanism | Protects against | Doesn't protect against |
|---|---|---|
| TLS | Eavesdropping in transit, MITM (with valid certificates) | Application-level attacks, compromised server |
| JWT | Unauthorized access (verifies user identity) | Replay attacks, request modification, client spoofing |
| Request signing | Request tampering, replay attacks, source spoofing | Eavesdropping (that's TLS), identity theft (that's JWT) |
Conclusion: You need all three. TLS protects the channel, JWT identifies the user, request signing guarantees the integrity and authenticity of the request.
The problem: the backend doesn't know who's really sending requests
Your backend receives a request with a valid JWT token. Great, the user is logged in. But how do you know that:
- The request comes from your app and not from curl/Postman?
- Nobody modified the body in transit?
- This isn't a replay attack - a repetition of an intercepted request?
SSL/TLS protects data in transit, but it doesn't answer these questions. You need an additional layer: request signing.
Anatomy of a signed request
Every request to sensitive endpoints should include:
X-Timestamp: 1708300800
X-Nonce: a1b2c3d4-e5f6-7890-abcd-ef1234567890
X-Signature: HMAC-SHA256(timestamp + nonce + method + path + body)
The backend verifies:
- Timestamp is within a Β±5 minute window (replay protection),
- Nonce hasn't been used before (additional replay protection),
- Signature matches the one computed on the server side.
Implementation in Swift
Let's start with a structure that encapsulates all the logic:
import Foundation
import CryptoKit
/// Request signing configuration
struct SigningConfig {
/// Maximum time difference between client and server (in seconds)
let maxClockSkew: TimeInterval = 300 // 5 minutes
/// Whether to use server time instead of local time
let useServerTime: Bool = true
/// Header names
let timestampHeader = "X-Timestamp"
let nonceHeader = "X-Nonce"
let signatureHeader = "X-Signature"
}
/// Request signer with HMAC-SHA256 support
final class RequestSigner {
private let secretKey: SymmetricKey
private let config: SigningConfig
private var serverTimeOffset: TimeInterval = 0
init(secretKeyData: Data, config: SigningConfig = SigningConfig()) {
self.secretKey = SymmetricKey(data: secretKeyData)
self.config = config
}
/// Synchronizes time with the server (call on app launch)
func syncTime(serverTimestamp: TimeInterval) {
let localTime = Date().timeIntervalSince1970
serverTimeOffset = serverTimestamp - localTime
}
/// Current timestamp (with server correction if enabled)
private var currentTimestamp: Int {
let base = Date().timeIntervalSince1970
let adjusted = config.useServerTime ? base + serverTimeOffset : base
return Int(adjusted)
}
/// Signs a URLRequest
func sign(_ request: inout URLRequest) {
let timestamp = String(currentTimestamp)
let nonce = UUID().uuidString
// Build the string to sign
let method = request.httpMethod ?? "GET"
let path = request.url?.path ?? "/"
let query = request.url?.query.map { "?\($0)" } ?? ""
let bodyHash = request.httpBody.map {
SHA256.hash(data: $0).compactMap { String(format: "%02x", $0) }.joined()
} ?? ""
let signatureBase = [timestamp, nonce, method, path + query, bodyHash]
.joined(separator: "\n")
// HMAC-SHA256
let signature = HMAC<SHA256>.authenticationCode(
for: Data(signatureBase.utf8),
using: secretKey
)
// Most backends expect DER format, not raw (compact)
// If backend wants raw β use .rawRepresentation
// If DER β use .derRepresentation
let signatureB64 = signature.derRepresentation.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
// Add headers
request.setValue(timestamp, forHTTPHeaderField: config.timestampHeader)
request.setValue(nonce, forHTTPHeaderField: config.nonceHeader)
request.setValue(signatureBase64, forHTTPHeaderField: config.signatureHeader)
}
}
An attacker's perspective: how can this be bypassed?
Before we move on, let's step into an attacker's shoes. What can go wrong?
Attack 1: Extracting the key from the app
If secretKeyData is hardcoded in the app, an attacker can:
- Download the IPA,
- Unpack it and use
stringsor Hopper/IDA, - Find the key and sign their own requests.
Defense: Never hardcode the key. Use one of these approaches:
- Per-user secret generated at registration and stored in Keychain
- Key in Secure Enclave (shown below)
- App Attest + assertion (next article in the series)
Attack 2: Replay within the time window
An attacker intercepts a request and replays it within 5 minutes.
Defense: The backend must store used nonces:
# Backend (Python/Redis example)
def verify_request(timestamp, nonce, signature):
# 1. Check time window
if abs(time.time() - int(timestamp)) > 300:
raise InvalidTimestamp()
# 2. Check if nonce has been used
if redis.exists(f"nonce:{nonce}"):
raise NonceReused()
# 3. Store nonce with TTL = time window
redis.setex(f"nonce:{nonce}", 300, "1")
# 4. Verify signature
# ...
Attack 3: Clock skew manipulation
An attacker sets the device time to the future, sends requests with future timestamps, then "replays" them when the time arrives.
Defense: That's why
useServerTime: trueis the default. Always sync with the server:
// On every response from the server
if let serverDate = response.value(forHTTPHeaderField: "Date") {
let formatter = DateFormatter()
formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
formatter.locale = Locale(identifier: "en_US_POSIX")
if let date = formatter.date(from: serverDate) {
requestSigner.syncTime(serverTimestamp: date.timeIntervalSince1970)
}
}
Level up: signing with Secure Enclave
For applications with the highest security requirements (fintech, healthcare) - the signing key should never leave hardware.
Secure Enclave is an isolated cryptographic coprocessor. Keys created inside it are never exported - not even to the main processor. You can only ask the Secure Enclave to perform operations.
import Security
import CryptoKit
/// Secure Enclave request signer
/// The private key never leaves hardware
final class SecureEnclaveRequestSigner {
private let privateKey: SecureEnclave.P256.Signing.PrivateKey
let publicKeyData: Data
/// Creates a new key pair in Secure Enclave
/// - Parameter accessControl: Access requirements (e.g. biometry)
init(
tag: String = "com.app.request-signing",
requireBiometry: Bool = false
) throws {
// Check if Secure Enclave is available
guard SecureEnclave.isAvailable else {
throw SigningError.secureEnclaveUnavailable
}
// Key access configuration
var accessFlags: SecAccessControlCreateFlags = .privateKeyUsage
if requireBiometry {
accessFlags.insert(.biometryCurrentSet)
}
let accessControl = SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
accessFlags,
nil
)!
// Generate the key
self.privateKey = try SecureEnclave.P256.Signing.PrivateKey(
compactRepresentable: true,
accessControl: accessControl
)
// Export only the public key (to send to the backend)
self.publicKeyData = privateKey.publicKey.rawRepresentation
}
/// Recovers an existing key from Secure Enclave
init(existingKeyData: Data) throws {
self.privateKey = try SecureEnclave.P256.Signing.PrivateKey(
dataRepresentation: existingKeyData
)
self.publicKeyData = privateKey.publicKey.rawRepresentation
}
/// Signs a request using ECDSA P-256
func sign(_ request: inout URLRequest) throws {
let timestamp = String(Int(Date().timeIntervalSince1970))
let nonce = UUID().uuidString
let method = request.httpMethod ?? "GET"
let path = request.url?.path ?? "/"
let bodyHash = request.httpBody.map {
SHA256.hash(data: $0).compactMap { String(format: "%02x", $0) }.joined()
} ?? ""
let signatureBase = [timestamp, nonce, method, path, bodyHash]
.joined(separator: "\n")
// ECDSA signature - operation performed INSIDE Secure Enclave
let signature = try privateKey.signature(
for: Data(signatureBase.utf8)
)
request.setValue(timestamp, forHTTPHeaderField: "X-Timestamp")
request.setValue(nonce, forHTTPHeaderField: "X-Nonce")
request.setValue(
signature.derRepresentation.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: ""),
forHTTPHeaderField: "X-Signature"
)
// Add public key ID so the backend knows which key to verify with
let keyId = SHA256.hash(data: publicKeyData)
.prefix(8)
.map { String(format: "%02x", $0) }
.joined()
request.setValue(keyId, forHTTPHeaderField: "X-Key-ID")
}
}
enum SigningError: Error {
case secureEnclaveUnavailable
}
The key difference: With HMAC, both sides know the same secret. With ECDSA from Secure Enclave, the backend only has the public key - even if someone compromises the backend, they can't sign requests as a user.
Comparison of signing methods
| Aspect | HMAC-SHA256 | ECDSA (Secure Enclave) | ML-DSA (quantum-resistant) |
|---|---|---|---|
| Cryptography type | Symmetric | Asymmetric | Asymmetric (post-quantum) |
| What the backend knows | The same secret | Only the public key | Only the public key |
| If key leaks from backend | Attacker can sign requests | Safe - backend doesn't have the private key | Safe |
| If key leaks from the app | Attacker can sign requests | Private key compromised | Private key compromised |
| Quantum protection | (AES-256 is sufficient) | Shor's algorithm breaks ECDSA | Resistant |
| Secure Enclave | N/A | Key never leaves hardware | Supported in iOS 26 on A17 Pro / M4+ |
| Performance | Fastest | Fast | Slower (larger signatures) |
| Signature size | 32 bytes | 64 bytes | ~2.4β2.6 KB (ML-DSA-65) or ~4.5β4.8 KB (ML-DSA-87) |
| HTTP header overhead | ~100 bytes | ~150β200 bytes | +2.4β4.8 kB per request |
Keep in mind that an ML-DSA-65 signature is over 2.4 kB - in apps with hundreds of requests per minute per user, this can mean a noticeable increase in data transfer (especially on weaker mobile connections). In many cases, ECDSA in Secure Enclave will still be a better trade-off until 2030β2035.
Recommendation:
- Most apps: HMAC with per-user secret in Keychain,
- Fintech/Healthcare: ECDSA with Secure Enclave,
- Ultra-sensitive + future-proof: ML-DSA (but account for the larger signature size).
Body canonicalization - the trap nobody talks about
JSON {"a":1,"b":2} and {"b":2,"a":1} are semantically the same, but their hash is different. This causes signatures to not match.
Solution: canonical JSON before hashing:
extension Data {
/// Canonical JSON: sorted keys
/// Note: we only use .sortedKeys - .withoutEscapingSlashes
/// can cause compatibility issues with some backends
func canonicalJSON() throws -> Data {
let object = try JSONSerialization.jsonObject(with: self)
return try JSONSerialization.data(
withJSONObject: object,
options: [.sortedKeys]
)
}
}
// In RequestSigner:
let bodyHash: String
if let body = request.httpBody {
let canonical = (try? body.canonicalJSON()) ?? body
bodyHash = SHA256.hash(data: canonical)
.compactMap { String(format: "%02x", $0) }
.joined()
} else {
bodyHash = ""
}
Note: If the backend uses a different JSON library, make sure both sides canonicalize identically. An alternative is hashing the raw body without canonicalization - simpler, but requires the client to always send JSON in the same format.
Canonicalization pitfalls - where signing most often breaks in production
The body is only part of the problem. Here's a full list of traps you'll encounter in production:
1. Query parameter order
/api/users?role=admin&status=active
/api/users?status=active&role=admin
Semantically the same, different hash.
Solution: Sort query params alphabetically before hashing:
extension URL {
/// Canonical query string: sorted parameters
var canonicalQuery: String {
guard
let components = URLComponents(url: self, resolvingAgainstBaseURL: false),
let queryItems = components.queryItems, !queryItems.isEmpty
else {
return ""
}
let sorted = queryItems
.sorted { $0.name < $1.name }
.map { "\($0.name)=\($0.value ?? "")" }
.joined(separator: "&")
return "?\(sorted)"
}
}
2. Path normalization
/users vs /users/
/Users vs /users (case sensitivity)
/users/../admin vs /admin
Solution: Establish a convention and stick to it:
extension URL {
/// Canonical path: lowercase, no trailing slash, resolved
var canonicalPath: String {
var path = self.standardized.path.lowercased()
if path.hasSuffix("/") && path != "/" {
path.removeLast()
}
return path
}
}
3. Special character encoding
/users/joseph vs /users/j%C3%B3zef
/search?q=hello world vs /search?q=hello%20world
Solution: Always use percent-encoding before hashing:
// In signatureBase use an already-encoded URL
let path = request.url?.absoluteString
.replacingOccurrences(of: request.url?.host ?? "", with: "")
// ... or use URLComponents which encodes automatically
4. Differences between server-side runtimes
| Language/framework | Default JSON key order | Query encoding | Notes |
|---|---|---|---|
| Swift (JSONSerialization) | Unspecified (use .sortedKeys) | RFC 3986 | Consistent with URLComponents |
| Node.js (JSON.stringify) | Preserves insertion order | Varies by lib | Use json-stable-stringify |
| Python (json.dumps) | Unspecified | urllib.parse | Use sort_keys=True |
| Go (encoding/json) | Alphabetical for structs | net/url | Sorted by default |
Pro tip: The best practice is to create a test suite with edge cases and run it on both the client and the server. Example test cases:
// Test cases for canonicalization
let testCases = [
("/users", "/users/", "trailing slash"),
("/Users", "/users", "case sensitivity"),
("/api?b=2&a=1", "/api?a=1&b=2", "query order"),
("/search?q=hello world", "/search?q=hello%20world", "encoding"),
("/{\"b\":1,\"a\":2}", "/{\"a\":2,\"b\":1}", "JSON key order"),
]
Most common request signing implementation mistakes (2025β2026):
- Nonces stored for only 300 seconds - an attacker waits 5:01 min and replays the request,
- No query parameter canonicalization -
/api?a=1&b=2β/api?b=2&a=1, - Server accepts old key exchange groups - downgrade attack possible even on iOS 26,
- Per-user secret never rotated (should be replaced every 90β180 days),
- Using local time without synchronization - clock skew attack (setting the clock forward),
- Missing JSON key sorting - different orderings β different hashes β signature fails,
- Hardcoding the HMAC key in the binary - easy to extract with strings / Frida / Objection.
Part 2: Hybrid post-quantum TLS in iOS 26
"Harvest now, decrypt later" - a real threat
The asymmetric algorithms we use (RSA, ECDH, ECDSA) are based on mathematical problems that quantum computers solve efficiently:
| Algorithm | Mathematical problem | Quantum attack |
|---|---|---|
| RSA | Factorization | Shor's algorithm - breaks in polynomial time |
| ECDH/ECDSA | Discrete logarithm on a curve | Shor's algorithm - also breaks |
| AES-256 | - | Grover's algorithm - weakens by 2x, but still secure |
The good news is that symmetric encryption (AES) remains secure. The problem is that key exchange (the part of TLS where you establish an AES key) uses asymmetric algorithms.
If someone records today's TLS handshake, in 15 years they can break it, extract the session key, and decrypt all communication.
What Apple did in iOS 26
At WWDC 2025, Apple announced support for hybrid post-quantum TLS based on NIST standards:
- ML-KEM (Module-Lattice Key Encapsulation Mechanism, formerly Kyber) - for key exchange,
- ML-DSA (Module-Lattice Digital Signature Algorithm, formerly Dilithium) - for digital signatures,
These algorithms are based on lattice problems, which quantum computers cannot solve efficiently.
Important: This is a hybrid approach - iOS 26 uses X25519 + ML-KEM768 together (known as X-Wing). This means that even if one of the algorithms turns out to be weaker than expected, the other still protects the connection. This is consistent with NIST and IETF recommendations for the transition period.
In CryptoKit, Apple exposes these algorithms at two levels:
-
High-level API: HPKE with X-Wing ciphersuite - based on X-Wing KEM (X25519 + ML-KEM768 in a single operation). This is the recommended approach for most use cases.
-
Low-level API: raw ML-KEM / ML-DSA - for advanced scenarios where you need full control.
Apple's recommendation: Use HPKE with the X-Wing ciphersuite instead of manually combining ML-KEM with X25519. Apple has optimized X-Wing for both performance and security.
Good news: you might already be quantum-secure
If you use URLSession or Network.framework on iOS 26, quantum-secure TLS is enabled by default:
// iOS 26 negotiates quantum-secure key exchange (X25519 + ML-KEM768),
// if the server announces and accepts the hybrid group.
// Otherwise it automatically falls back to classic X25519.
let session = URLSession.shared
let (data, response) = try await session.data(from: url)
One condition: the server must support hybrid key exchange groups. iOS 26 sends X25519MLKEM768 by default in supported_groups and key_share in ClientHello - if the server supports it, the connection will be quantum-secure. Otherwise iOS automatically falls back to classic ECDH.
How to check if a connection is quantum-secure
import Network
func checkQuantumSecurity(for host: String) {
let connection = NWConnection(
host: NWEndpoint.Host(host),
port: .https,
using: .tls
)
connection.stateUpdateHandler = { state in
if case .ready = state {
// Fetch TLS metadata
if let metadata = connection.metadata(definition: NWProtocolTLS.definition),
let secProtocol = metadata as? NWProtocolTLS.Metadata {
let protocolVersion = secProtocol.securityProtocolMetadata
// Check the negotiated key exchange
// In iOS 26 you can verify whether ML-KEM was used
print("Connection ready")
// TODO: Apple hasn't yet exposed a public API
// to check the specific key exchange algorithm used
}
}
}
connection.start(queue: .main)
}
Status as of February 2026: Apple does not provide a direct public API to check whether a specific connection used quantum-secure key exchange. The most reliable verification methods are:
- macOS Tahoe 26:
nscurl --verbose https://example.comβ look forNegotiated TLS key exchange group: X25519MLKEM768,- Packet analysis (Wireshark) - look for
X25519MLKEM768in Client Hello,- Server logs - check the negotiated cipher suite,
- Network.framework debugger -
NWProtocolTLS.Metadataexposes some details.
CryptoKit: quantum-secure API for custom protocols
If you're implementing your own encryption protocol (e.g. E2E encryption in a chat app), you have two options:
Option 1: HPKE with X-Wing ciphersuite (recommended)
The simplest and most secure approach. Apple hides the complexity of X-Wing KEM behind a clean HPKE API:
import CryptoKit
// ===== HPKE with X-Wing (recommended) =====
// Recipient generates an X-Wing key pair (hybrid X25519 + ML-KEM768)
let privateKey = try XWingMLKEM768X25519.PrivateKey.generate()
let publicKey = privateKey.publicKey
// Ciphersuite - quantum-secure HPKE
let ciphersuite = HPKE.Ciphersuite.XWingMLKEM768X25519_SHA256_AES_GCM_256
// Sender encrypts
var sender = try HPKE.Sender(
recipientKey: publicKey,
ciphersuite: ciphersuite,
info: Data("app-e2e-v1".utf8)
)
let metadata = Data("user-id:12345".utf8)
let ciphertext = try sender.seal(plaintext, authenticating: metadata)
let encapsulatedKey = sender.encapsulatedKey
// Recipient decrypts
var recipient = try HPKE.Recipient(
privateKey: privateKey,
ciphersuite: ciphersuite,
encapsulatedKey: encapsulatedKey,
info: Data("app-e2e-v1".utf8)
)
let decrypted = try recipient.open(ciphertext, authenticating: metadata)
Associated data (AAD): The
authenticating:parameter lets you bind the ciphertext to additional data (e.g. user ID, timestamp). This data is not encrypted, but it is authenticated - any change to the AAD will cause decryption to fail.
Option 2: Low-level ML-KEM (for advanced use)
If you need full control over the key exchange:
import CryptoKit
// ===== Raw ML-KEM: key encapsulation =====
// Receiving side generates a key pair
let recipientPrivateKey = try MLKEM768.PrivateKey()
let recipientPublicKey = recipientPrivateKey.publicKey
// Sends the public key to the sender...
// Sending side encapsulates the shared secret
let (sharedSecret, encapsulation) = try recipientPublicKey.encapsulate()
// Sends `encapsulation` to the recipient...
// Receiving side decapsulates
let decapsulatedSecret = try recipientPrivateKey.decapsulate(encapsulation)
// Both secrets are identical!
assert(sharedSecret == decapsulatedSecret)
// Use for symmetric encryption
let symmetricKey = SymmetricKey(data: sharedSecret)
let encrypted = try ChaChaPoly.seal(message, using: symmetricKey)
Note: Raw ML-KEM requires manual key and protocol management. For most applications, HPKE with the X-Wing ciphersuite is a better choice.
Hybrid approach - best practice
NIST and cryptography experts recommend combining classical and quantum-resistant algorithms. If one turns out to be broken, the other still provides protection.
Ready-made solution: X-Wing KEM
From iOS 26, Apple provides X-Wing - a ready-to-use implementation of X25519 + ML-KEM768 in a single operation. You use it automatically via HPKE with the appropriate ciphersuite (shown above).
Manual implementation (only if you really have to)
If for some reason you need to compose a hybrid key exchange manually:
/// Hybrid key exchange: X25519 + ML-KEM
/// Prefer post-quantum HPKE over this!
func hybridKeyExchange(
peerX25519PublicKey: Curve25519.KeyAgreement.PublicKey,
peerMLKEMPublicKey: MLKEM768.PublicKey
) throws -> SymmetricKey {
// 1. Classic key exchange (X25519)
let x25519PrivateKey = Curve25519.KeyAgreement.PrivateKey()
let x25519SharedSecret = try x25519PrivateKey.sharedSecretFromKeyAgreement(
with: peerX25519PublicKey
)
// 2. Quantum-resistant (ML-KEM)
let (mlkemSharedSecret, encapsulation) = try peerMLKEMPublicKey.encapsulate()
// 3. Combine both secrets via KDF
let combinedSecret = x25519SharedSecret.withUnsafeBytes { x25519Bytes in
mlkemSharedSecret.withUnsafeBytes { mlkemBytes in
var combined = Data(x25519Bytes)
combined.append(contentsOf: mlkemBytes)
return combined
}
}
// 4. Derive the final key
let finalKey = HKDF<SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: combinedSecret),
info: Data("hybrid-key-v1".utf8),
outputByteCount: 32
)
return finalKey
// Remember to also send: x25519PrivateKey.publicKey and encapsulation
}
Pro tip: From iOS 26, ML-KEM and ML-DSA keys can be generated and used directly in Secure Enclave - just like P-256 before them. For the most sensitive data, you can have quantum-resistant keys that never leave hardware.
An attacker's perspective: quantum edition
Attack 1: Downgrade attack
An attacker performs a MITM and forces the use of classic TLS without quantum-secure key exchange.
Defense: iOS 26 negotiates hybridly - even if ML-KEM fails, classic ECDH still works. But make sure your server does not accept old, weak cipher suites.
Attack 2: "Harvest now" still works for older connections
If your app supports iOS 15+, users on older systems don't have quantum protection.
Defense: For ultra-sensitive data, add a layer of E2E encryption with ML-KEM at the application layer:
// Encrypt the payload BEFORE sending it over TLS
let encryptedPayload = try encryptWithHybridScheme(
message: sensitiveData,
recipientPublicKey: serverPublicKey
)
// Even if TLS is broken, the payload is still secure
request.httpBody = encryptedPayload
Request signing β certificate pinning
Before we get to the checklist, one important note: request signing does not protect against all MITM attacks.
Imagine this scenario:
- A user connects through a corporate proxy with its own CA certificate,
- The proxy terminates TLS, decrypts the traffic, re-encrypts it with its own certificate,
- Your app signs its requests... which the proxy can read, modify, and re-sign (if it knows the key).
| Attack | Request signing | Certificate pinning |
|---|---|---|
| Request modification in transit | Protects | Protects (prevents MITM entirely) |
| Replay attack | Protects (nonce + timestamp) | Not applicable |
| MITM with custom CA (corporate proxy) | Partially* | Protects |
| SSL interception tools (Charles, Proxyman) | Partially* | Protects |
*If the attacker doesn't know the signing key, they can't craft their own requests. But they can intercept and analyze traffic.
Conclusion: Request signing and certificate pinning are complementary security layers. In the next article in the series I'll show you how to implement dynamic SSL pinning - because static certificate pinning is just the beginning.
Summary: your security checklist
Request signing:
- Every sensitive request includes timestamp + nonce + signature,
- Backend verifies the time window (Β±5 min),
- Backend stores used nonces (Redis/Memcached with TTL),
- Time synchronized with the server, not from the device,
- Body is canonicalized before hashing,
- For high-security apps: signing key stored in Secure Enclave.
Hybrid post-quantum communication:
- App on iOS 26 uses
URLSessionorNetwork.framework, - Backend supports and announces hybrid groups (X25519MLKEM768),
- For E2E encryption: use HPKE with X-Wing ciphersuite (recommended) or raw ML-KEM,
- ML-KEM/ML-DSA keys in Secure Enclave for the most sensitive data,
- Consider the impact of larger ML-DSA signatures on bandwidth.
What's next?
The next article in the series will cover dynamic SSL pinning - why static pinning isn't enough, how to safely rotate certificates without updating the app, and how to detect when someone is trying to bypass your security measures.
And the third one - App Attest and DeviceCheck - how to prove to the backend that a request comes from an unmodified instance of your app.

RafaΕ Dubiel
Senior iOS Developer with 8+ years of experience building mobile applications. Passionate about clean architecture, Swift, and sharing knowledge with the community.
Share this article: