Mobile App Certificate Pinning: Underlying Principle and a Swift Example
How Charles Proxy Works explained how Charles Proxy performs HTTPS MITM inspection by getting the client to trust a fake Root CA. Section 11 of that document mentioned that SSL/Certificate Pinning defeats this attack.
This document goes one level deeper: why does pinning work, exactly, and how do you implement it in an iOS app with Swift?
Table of Contents
- [The Trust Gap That Pinning Closes](#The Trust Gap That Pinning Closes)
- [What "Pinning" Actually Means](#What “Pinning” Actually Means)
- [Two Pinning Strategies: Certificate vs Public Key](#Two Pinning Strategies: Certificate vs Public Key)
- [Where Pinning Hooks Into the TLS Handshake](#Where Pinning Hooks Into the TLS Handshake)
- [Why This Blocks Charles/MITM Proxies](#Why This Blocks Charles/MITM Proxies)
- [Swift Example: Public Key Pinning with URLSession](#Swift Example: Public Key Pinning with URLSession)
- [Swift Example: Certificate Pinning](#Swift Example: Certificate Pinning)
- [Handling Certificate Rotation](#Handling Certificate Rotation)
- [Limitations and Trade-offs](#Limitations and Trade-offs)
- Summary
1. The Trust Gap That Pinning Closes
Normal HTTPS validation asks one question:
Was this certificate signed by any CA that the OS trusts?
The OS trust store contains hundreds of CAs. If any one of them --- or anything the user has installed, like a Charles Root CA, a corporate MDM certificate, or malware --- is willing to vouch for a certificate, the connection is accepted:
#mermaid-svg-vndCxdAlvg7HrLyD{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-vndCxdAlvg7HrLyD .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-vndCxdAlvg7HrLyD .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-vndCxdAlvg7HrLyD .error-icon{fill:#552222;}#mermaid-svg-vndCxdAlvg7HrLyD .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-vndCxdAlvg7HrLyD .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-vndCxdAlvg7HrLyD .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-vndCxdAlvg7HrLyD .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-vndCxdAlvg7HrLyD .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-vndCxdAlvg7HrLyD .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-vndCxdAlvg7HrLyD .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-vndCxdAlvg7HrLyD .marker{fill:#333333;stroke:#333333;}#mermaid-svg-vndCxdAlvg7HrLyD .marker.cross{stroke:#333333;}#mermaid-svg-vndCxdAlvg7HrLyD svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-vndCxdAlvg7HrLyD p{margin:0;}#mermaid-svg-vndCxdAlvg7HrLyD .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-vndCxdAlvg7HrLyD .cluster-label text{fill:#333;}#mermaid-svg-vndCxdAlvg7HrLyD .cluster-label span{color:#333;}#mermaid-svg-vndCxdAlvg7HrLyD .cluster-label span p{background-color:transparent;}#mermaid-svg-vndCxdAlvg7HrLyD .label text,#mermaid-svg-vndCxdAlvg7HrLyD span{fill:#333;color:#333;}#mermaid-svg-vndCxdAlvg7HrLyD .node rect,#mermaid-svg-vndCxdAlvg7HrLyD .node circle,#mermaid-svg-vndCxdAlvg7HrLyD .node ellipse,#mermaid-svg-vndCxdAlvg7HrLyD .node polygon,#mermaid-svg-vndCxdAlvg7HrLyD .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-vndCxdAlvg7HrLyD .rough-node .label text,#mermaid-svg-vndCxdAlvg7HrLyD .node .label text,#mermaid-svg-vndCxdAlvg7HrLyD .image-shape .label,#mermaid-svg-vndCxdAlvg7HrLyD .icon-shape .label{text-anchor:middle;}#mermaid-svg-vndCxdAlvg7HrLyD .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-vndCxdAlvg7HrLyD .rough-node .label,#mermaid-svg-vndCxdAlvg7HrLyD .node .label,#mermaid-svg-vndCxdAlvg7HrLyD .image-shape .label,#mermaid-svg-vndCxdAlvg7HrLyD .icon-shape .label{text-align:center;}#mermaid-svg-vndCxdAlvg7HrLyD .node.clickable{cursor:pointer;}#mermaid-svg-vndCxdAlvg7HrLyD .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-vndCxdAlvg7HrLyD .arrowheadPath{fill:#333333;}#mermaid-svg-vndCxdAlvg7HrLyD .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-vndCxdAlvg7HrLyD .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-vndCxdAlvg7HrLyD .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vndCxdAlvg7HrLyD .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-vndCxdAlvg7HrLyD .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vndCxdAlvg7HrLyD .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-vndCxdAlvg7HrLyD .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-vndCxdAlvg7HrLyD .cluster text{fill:#333;}#mermaid-svg-vndCxdAlvg7HrLyD .cluster span{color:#333;}#mermaid-svg-vndCxdAlvg7HrLyD div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-vndCxdAlvg7HrLyD .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-vndCxdAlvg7HrLyD rect.text{fill:none;stroke-width:0;}#mermaid-svg-vndCxdAlvg7HrLyD .icon-shape,#mermaid-svg-vndCxdAlvg7HrLyD .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vndCxdAlvg7HrLyD .icon-shape p,#mermaid-svg-vndCxdAlvg7HrLyD .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-vndCxdAlvg7HrLyD .icon-shape .label rect,#mermaid-svg-vndCxdAlvg7HrLyD .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vndCxdAlvg7HrLyD .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-vndCxdAlvg7HrLyD .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-vndCxdAlvg7HrLyD :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Yes --- could be DigiCert,
could be Charles Root CA
Presented certificate
for api.example.com
Signed by ANY
trusted CA?
✅ Accepted
This is the exact gap Charles walks through: it doesn't need to compromise DigiCert, it just needs one entry in the trust store --- the one the user installed themselves.
Pinning closes that gap by refusing to ask "any trusted CA?" and instead asking a much narrower question:
Does this certificate match the one specific certificate (or key) I already know belongs to my server?
#mermaid-svg-6z6Sl9dIkgkrRvAD{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-6z6Sl9dIkgkrRvAD .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-6z6Sl9dIkgkrRvAD .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-6z6Sl9dIkgkrRvAD .error-icon{fill:#552222;}#mermaid-svg-6z6Sl9dIkgkrRvAD .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-6z6Sl9dIkgkrRvAD .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-6z6Sl9dIkgkrRvAD .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-6z6Sl9dIkgkrRvAD .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-6z6Sl9dIkgkrRvAD .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-6z6Sl9dIkgkrRvAD .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-6z6Sl9dIkgkrRvAD .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-6z6Sl9dIkgkrRvAD .marker{fill:#333333;stroke:#333333;}#mermaid-svg-6z6Sl9dIkgkrRvAD .marker.cross{stroke:#333333;}#mermaid-svg-6z6Sl9dIkgkrRvAD svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-6z6Sl9dIkgkrRvAD p{margin:0;}#mermaid-svg-6z6Sl9dIkgkrRvAD .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-6z6Sl9dIkgkrRvAD .cluster-label text{fill:#333;}#mermaid-svg-6z6Sl9dIkgkrRvAD .cluster-label span{color:#333;}#mermaid-svg-6z6Sl9dIkgkrRvAD .cluster-label span p{background-color:transparent;}#mermaid-svg-6z6Sl9dIkgkrRvAD .label text,#mermaid-svg-6z6Sl9dIkgkrRvAD span{fill:#333;color:#333;}#mermaid-svg-6z6Sl9dIkgkrRvAD .node rect,#mermaid-svg-6z6Sl9dIkgkrRvAD .node circle,#mermaid-svg-6z6Sl9dIkgkrRvAD .node ellipse,#mermaid-svg-6z6Sl9dIkgkrRvAD .node polygon,#mermaid-svg-6z6Sl9dIkgkrRvAD .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-6z6Sl9dIkgkrRvAD .rough-node .label text,#mermaid-svg-6z6Sl9dIkgkrRvAD .node .label text,#mermaid-svg-6z6Sl9dIkgkrRvAD .image-shape .label,#mermaid-svg-6z6Sl9dIkgkrRvAD .icon-shape .label{text-anchor:middle;}#mermaid-svg-6z6Sl9dIkgkrRvAD .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-6z6Sl9dIkgkrRvAD .rough-node .label,#mermaid-svg-6z6Sl9dIkgkrRvAD .node .label,#mermaid-svg-6z6Sl9dIkgkrRvAD .image-shape .label,#mermaid-svg-6z6Sl9dIkgkrRvAD .icon-shape .label{text-align:center;}#mermaid-svg-6z6Sl9dIkgkrRvAD .node.clickable{cursor:pointer;}#mermaid-svg-6z6Sl9dIkgkrRvAD .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-6z6Sl9dIkgkrRvAD .arrowheadPath{fill:#333333;}#mermaid-svg-6z6Sl9dIkgkrRvAD .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-6z6Sl9dIkgkrRvAD .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-6z6Sl9dIkgkrRvAD .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6z6Sl9dIkgkrRvAD .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-6z6Sl9dIkgkrRvAD .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6z6Sl9dIkgkrRvAD .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-6z6Sl9dIkgkrRvAD .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-6z6Sl9dIkgkrRvAD .cluster text{fill:#333;}#mermaid-svg-6z6Sl9dIkgkrRvAD .cluster span{color:#333;}#mermaid-svg-6z6Sl9dIkgkrRvAD div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-6z6Sl9dIkgkrRvAD .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-6z6Sl9dIkgkrRvAD rect.text{fill:none;stroke-width:0;}#mermaid-svg-6z6Sl9dIkgkrRvAD .icon-shape,#mermaid-svg-6z6Sl9dIkgkrRvAD .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6z6Sl9dIkgkrRvAD .icon-shape p,#mermaid-svg-6z6Sl9dIkgkrRvAD .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-6z6Sl9dIkgkrRvAD .icon-shape .label rect,#mermaid-svg-6z6Sl9dIkgkrRvAD .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6z6Sl9dIkgkrRvAD .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-6z6Sl9dIkgkrRvAD .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-6z6Sl9dIkgkrRvAD :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} No --- even if signed
by a trusted CA
Yes
Presented certificate
for api.example.com
Matches the exact
cert/key pinned in the app?
❌ Rejected
✅ Accepted
The OS trust store becomes irrelevant. The app carries its own, much smaller "trust store" of one.
2. What "Pinning" Actually Means
"Pinning" is just baking an expected value into the app binary at build time, and comparing the server's presented certificate against it at connection time:
| What gets pinned | Stored as |
|---|---|
| The full leaf certificate | A .cer / .der file bundled in the app |
| Just the public key | A SHA-256 hash of the SubjectPublicKeyInfo (SPKI) |
| An intermediate/root CA | A .cer file, when pinning to your own private CA |
Because the value lives inside the compiled app rather than in a system-level trust store, an attacker (or a debugging proxy like Charles) cannot simply add a new trusted CA to the device and expect the app to accept it --- the app never consults the device's trust store for this decision.
3. Two Pinning Strategies: Certificate vs Public Key
| Pin the certificate | Pin the public key | |
|---|---|---|
| What's compared | Byte-for-byte DER of the cert | SHA-256 hash of the SPKI (public key) |
| Breaks on cert renewal? | Yes --- a renewed cert is a new file, even with the same key | No --- as long as the key pair is reused |
| Breaks on key rotation? | Yes | Yes |
| Common in practice | Simpler to reason about, but operationally fragile | Preferred --- survives routine cert renewal (e.g. yearly Let's Encrypt reissue) as long as the private key doesn't change |
Most production implementations (and Apple's own NSPinnedDomains App Transport Security feature) pin the public key hash, not the full certificate, for exactly this reason.
4. Where Pinning Hooks Into the TLS Handshake
Recall from the Charles doc: during the TLS handshake, the server presents its certificate chain before any symmetric session key is derived. Pinning adds one extra check at that exact moment --- after the OS's own chain-of-trust validation, but before the app treats the connection as usable:
Server OS TLS stack App Server OS TLS stack App #mermaid-svg-WIDJa1eZjxO818IN{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-WIDJa1eZjxO818IN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-WIDJa1eZjxO818IN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-WIDJa1eZjxO818IN .error-icon{fill:#552222;}#mermaid-svg-WIDJa1eZjxO818IN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-WIDJa1eZjxO818IN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-WIDJa1eZjxO818IN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-WIDJa1eZjxO818IN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-WIDJa1eZjxO818IN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-WIDJa1eZjxO818IN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-WIDJa1eZjxO818IN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-WIDJa1eZjxO818IN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-WIDJa1eZjxO818IN .marker.cross{stroke:#333333;}#mermaid-svg-WIDJa1eZjxO818IN svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-WIDJa1eZjxO818IN p{margin:0;}#mermaid-svg-WIDJa1eZjxO818IN .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-WIDJa1eZjxO818IN text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-WIDJa1eZjxO818IN .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-WIDJa1eZjxO818IN .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-WIDJa1eZjxO818IN .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-WIDJa1eZjxO818IN .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-WIDJa1eZjxO818IN #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-WIDJa1eZjxO818IN .sequenceNumber{fill:white;}#mermaid-svg-WIDJa1eZjxO818IN #sequencenumber{fill:#333;}#mermaid-svg-WIDJa1eZjxO818IN #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-WIDJa1eZjxO818IN .messageText{fill:#333;stroke:none;}#mermaid-svg-WIDJa1eZjxO818IN .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-WIDJa1eZjxO818IN .labelText,#mermaid-svg-WIDJa1eZjxO818IN .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-WIDJa1eZjxO818IN .loopText,#mermaid-svg-WIDJa1eZjxO818IN .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-WIDJa1eZjxO818IN .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-WIDJa1eZjxO818IN .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-WIDJa1eZjxO818IN .noteText,#mermaid-svg-WIDJa1eZjxO818IN .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-WIDJa1eZjxO818IN .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-WIDJa1eZjxO818IN .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-WIDJa1eZjxO818IN .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-WIDJa1eZjxO818IN .actorPopupMenu{position:absolute;}#mermaid-svg-WIDJa1eZjxO818IN .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-WIDJa1eZjxO818IN .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-WIDJa1eZjxO818IN .actor-man circle,#mermaid-svg-WIDJa1eZjxO818IN line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-WIDJa1eZjxO818IN :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt Matches Does not match ClientHello Certificate chain Standard validation (CA trust, domain, expiry) EXTRA CHECK --- does leaf cert / public key match the pinned value? Continue handshake, derive session key Abort connection, no data is sent
In iOS, this hook point is the URLSession delegate method urlSession(_:didReceive:completionHandler:), which fires on every TLS challenge and gives you the raw SecTrust object containing the server's certificate chain --- before any request data is sent.
5. Why This Blocks Charles/MITM Proxies
Walk through the Charles MITM flow from the referenced doc, now with pinning active:
- Client sends
CONNECT api.example.com:443to Charles. - Charles mints a certificate:
Subject: api.example.com,Issuer: Charles Root CA. - Normal HTTPS: OS checks "is Charles Root CA trusted?" → yes (user installed it) → accepted.
- With pinning: the app doesn't ask the OS that question at all for the purposes of pinning. It extracts the public key from whatever certificate was presented and compares its hash to the hardcoded value.
#mermaid-svg-3hpipxYVgxn4vLkb{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-3hpipxYVgxn4vLkb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3hpipxYVgxn4vLkb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3hpipxYVgxn4vLkb .error-icon{fill:#552222;}#mermaid-svg-3hpipxYVgxn4vLkb .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3hpipxYVgxn4vLkb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3hpipxYVgxn4vLkb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3hpipxYVgxn4vLkb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3hpipxYVgxn4vLkb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3hpipxYVgxn4vLkb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3hpipxYVgxn4vLkb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3hpipxYVgxn4vLkb .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3hpipxYVgxn4vLkb .marker.cross{stroke:#333333;}#mermaid-svg-3hpipxYVgxn4vLkb svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3hpipxYVgxn4vLkb p{margin:0;}#mermaid-svg-3hpipxYVgxn4vLkb .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-3hpipxYVgxn4vLkb .cluster-label text{fill:#333;}#mermaid-svg-3hpipxYVgxn4vLkb .cluster-label span{color:#333;}#mermaid-svg-3hpipxYVgxn4vLkb .cluster-label span p{background-color:transparent;}#mermaid-svg-3hpipxYVgxn4vLkb .label text,#mermaid-svg-3hpipxYVgxn4vLkb span{fill:#333;color:#333;}#mermaid-svg-3hpipxYVgxn4vLkb .node rect,#mermaid-svg-3hpipxYVgxn4vLkb .node circle,#mermaid-svg-3hpipxYVgxn4vLkb .node ellipse,#mermaid-svg-3hpipxYVgxn4vLkb .node polygon,#mermaid-svg-3hpipxYVgxn4vLkb .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-3hpipxYVgxn4vLkb .rough-node .label text,#mermaid-svg-3hpipxYVgxn4vLkb .node .label text,#mermaid-svg-3hpipxYVgxn4vLkb .image-shape .label,#mermaid-svg-3hpipxYVgxn4vLkb .icon-shape .label{text-anchor:middle;}#mermaid-svg-3hpipxYVgxn4vLkb .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-3hpipxYVgxn4vLkb .rough-node .label,#mermaid-svg-3hpipxYVgxn4vLkb .node .label,#mermaid-svg-3hpipxYVgxn4vLkb .image-shape .label,#mermaid-svg-3hpipxYVgxn4vLkb .icon-shape .label{text-align:center;}#mermaid-svg-3hpipxYVgxn4vLkb .node.clickable{cursor:pointer;}#mermaid-svg-3hpipxYVgxn4vLkb .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-3hpipxYVgxn4vLkb .arrowheadPath{fill:#333333;}#mermaid-svg-3hpipxYVgxn4vLkb .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-3hpipxYVgxn4vLkb .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-3hpipxYVgxn4vLkb .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3hpipxYVgxn4vLkb .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-3hpipxYVgxn4vLkb .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3hpipxYVgxn4vLkb .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-3hpipxYVgxn4vLkb .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-3hpipxYVgxn4vLkb .cluster text{fill:#333;}#mermaid-svg-3hpipxYVgxn4vLkb .cluster span{color:#333;}#mermaid-svg-3hpipxYVgxn4vLkb div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-3hpipxYVgxn4vLkb .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-3hpipxYVgxn4vLkb rect.text{fill:none;stroke-width:0;}#mermaid-svg-3hpipxYVgxn4vLkb .icon-shape,#mermaid-svg-3hpipxYVgxn4vLkb .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3hpipxYVgxn4vLkb .icon-shape p,#mermaid-svg-3hpipxYVgxn4vLkb .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-3hpipxYVgxn4vLkb .icon-shape .label rect,#mermaid-svg-3hpipxYVgxn4vLkb .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3hpipxYVgxn4vLkb .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-3hpipxYVgxn4vLkb .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-3hpipxYVgxn4vLkb :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Charles's key ≠ real server's key
Charles presents:
fake cert, signed by Charles Root CA
App's pinned hash:
SHA-256(real server's public key)
Extract SPKI from
presented cert
hash == pinned hash?
❌ Connection aborted
by the app itself
Charles's fake certificate carries Charles's own key pair , not the real server's. No amount of OS-level trust can make Charles's public key hash equal to the pinned value, because Charles doesn't possess --- and cannot possess --- the real server's private key. This is the same reason patching the OS trust store, using a "more convincing" fake CA, or getting a real CA to mis-issue a certificate all fail: pinning doesn't ask who signed the cert, it asks whether the key is the one specific key the app already knows about.
6. Swift Example: Public Key Pinning with URLSession
This is the recommended approach --- pin the SHA-256 hash of the server's public key (SPKI).
swift
import Foundation
import CommonCrypto
final class PinningURLSessionDelegate: NSObject, URLSessionDelegate {
/// SHA-256 hashes of the expected SubjectPublicKeyInfo, base64-encoded.
/// Generate with:
/// openssl s_client -connect api.example.com:443 </dev/null 2>/dev/null \
/// | openssl x509 -pubkey -noout \
/// | openssl pkey -pubin -outform der \
/// | openssl dgst -sha256 -binary | base64
private let pinnedPublicKeyHashes: Set<String> = [
"wF7g6t3+PsMWQ4nDXTOFGO5t/7BsdEEnkZWt3AmzZ8I=", // current key
"9SLklkLE1lm/lB9EhaGgUwFvJXfXV3+Sg5NGSCLLpQg=" // next key, pre-provisioned for rotation
]
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let serverTrust = challenge.protectionSpace.serverTrust else {
completionHandler(.performDefaultHandling, nil)
return
}
// 1. Let the OS do the normal chain-of-trust / expiry / hostname checks first.
var error: CFError?
guard SecTrustEvaluateWithError(serverTrust, &error) else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// 2. Extract the leaf certificate's public key and hash it.
guard let serverCertificate = (SecTrustCopyCertificateChain(serverTrust) as? [SecCertificate])?.first,
let serverPublicKey = SecCertificateCopyKey(serverCertificate),
let serverPublicKeyData = SecKeyCopyExternalRepresentation(serverPublicKey, nil) as Data? else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let hash = sha256Base64(spkiDER(from: serverPublicKeyData))
// 3. Compare against the pinned set. Anything else --- including a
// perfectly valid, OS-trusted certificate --- is rejected.
if pinnedPublicKeyHashes.contains(hash) {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
private func sha256Base64(_ data: Data) -> String {
var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
data.withUnsafeBytes { buffer in
_ = CC_SHA256(buffer.baseAddress, CC_LONG(data.count), &digest)
}
return Data(digest).base64EncodedString()
}
/// SecKeyCopyExternalRepresentation returns the raw key, not full SPKI DER,
/// so prepend the algorithm-specific ASN.1 header before hashing.
private func spkiDER(from rawKey: Data) -> Data {
let rsa2048Header: [UInt8] = [
0x30, 0x82, 0x01, 0x22, 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86,
0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00, 0x03, 0x82, 0x01, 0x0f, 0x00
]
return Data(rsa2048Header) + rawKey
}
}
// Usage:
let session = URLSession(
configuration: .default,
delegate: PinningURLSessionDelegate(),
delegateQueue: nil
)
session.dataTask(with: URL(string: "https://api.example.com/user")!) { data, response, error in
// handle response
}.resume()
Key points:
SecTrustEvaluateWithErrorstill runs the standard OS validation first --- pinning is an additional layer, not a replacement for it.- The comparison uses the public key hash, computed from data delegates already receive during the handshake, before any request body is sent.
- Two hashes are pinned at once --- the currently deployed key and the next one --- to support rotation without an app update (see [Section 8](#Section 8)).
7. Swift Example: Certificate Pinning
For completeness, here is the simpler but more brittle alternative --- pinning the exact certificate bytes.
swift
final class CertPinningDelegate: NSObject, URLSessionDelegate {
private lazy var pinnedCertificateData: Data = {
let path = Bundle.main.path(forResource: "api-example-com", ofType: "cer")!
return try! Data(contentsOf: URL(fileURLWithPath: path))
}()
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let serverTrust = challenge.protectionSpace.serverTrust,
let serverCertificate = (SecTrustCopyCertificateChain(serverTrust) as? [SecCertificate])?.first else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let serverCertData = SecCertificateCopyData(serverCertificate) as Data
if serverCertData == pinnedCertificateData {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
}
api-example-com.cer is exported once (openssl s_client ... | openssl x509 -outform der -out api-example-com.cer) and bundled as an app resource. Every certificate renewal --- even with an identical key pair --- invalidates this pin and requires a new app release, which is why public-key pinning is generally preferred.
8. Handling Certificate Rotation
Because the pin lives inside a compiled, App-Store-reviewed binary, rotating the server's certificate/key with no coordination will hard-break every installed copy of the app that has the old pin baked in. Two common mitigations:
- Pin the public key, and pre-provision the next key before rotating, as shown in Section 6's
pinnedPublicKeyHashesset. Since the same key pair can be reused across a certificate renewal, this alone avoids most routine renewal breakage. - Backup pins : when actually rotating to a new key pair, generate the new certificate ahead of time, ship an app update containing both the old and new key hashes, wait for adoption to reach acceptable levels, then rotate the server --- old app versions still validate against the pin they shipped with until they're updated.
#mermaid-svg-Qr15WAAPiZA0o6N0{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Qr15WAAPiZA0o6N0 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Qr15WAAPiZA0o6N0 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Qr15WAAPiZA0o6N0 .error-icon{fill:#552222;}#mermaid-svg-Qr15WAAPiZA0o6N0 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Qr15WAAPiZA0o6N0 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Qr15WAAPiZA0o6N0 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Qr15WAAPiZA0o6N0 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Qr15WAAPiZA0o6N0 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Qr15WAAPiZA0o6N0 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Qr15WAAPiZA0o6N0 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Qr15WAAPiZA0o6N0 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Qr15WAAPiZA0o6N0 .marker.cross{stroke:#333333;}#mermaid-svg-Qr15WAAPiZA0o6N0 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Qr15WAAPiZA0o6N0 p{margin:0;}#mermaid-svg-Qr15WAAPiZA0o6N0 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Qr15WAAPiZA0o6N0 .cluster-label text{fill:#333;}#mermaid-svg-Qr15WAAPiZA0o6N0 .cluster-label span{color:#333;}#mermaid-svg-Qr15WAAPiZA0o6N0 .cluster-label span p{background-color:transparent;}#mermaid-svg-Qr15WAAPiZA0o6N0 .label text,#mermaid-svg-Qr15WAAPiZA0o6N0 span{fill:#333;color:#333;}#mermaid-svg-Qr15WAAPiZA0o6N0 .node rect,#mermaid-svg-Qr15WAAPiZA0o6N0 .node circle,#mermaid-svg-Qr15WAAPiZA0o6N0 .node ellipse,#mermaid-svg-Qr15WAAPiZA0o6N0 .node polygon,#mermaid-svg-Qr15WAAPiZA0o6N0 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Qr15WAAPiZA0o6N0 .rough-node .label text,#mermaid-svg-Qr15WAAPiZA0o6N0 .node .label text,#mermaid-svg-Qr15WAAPiZA0o6N0 .image-shape .label,#mermaid-svg-Qr15WAAPiZA0o6N0 .icon-shape .label{text-anchor:middle;}#mermaid-svg-Qr15WAAPiZA0o6N0 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Qr15WAAPiZA0o6N0 .rough-node .label,#mermaid-svg-Qr15WAAPiZA0o6N0 .node .label,#mermaid-svg-Qr15WAAPiZA0o6N0 .image-shape .label,#mermaid-svg-Qr15WAAPiZA0o6N0 .icon-shape .label{text-align:center;}#mermaid-svg-Qr15WAAPiZA0o6N0 .node.clickable{cursor:pointer;}#mermaid-svg-Qr15WAAPiZA0o6N0 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Qr15WAAPiZA0o6N0 .arrowheadPath{fill:#333333;}#mermaid-svg-Qr15WAAPiZA0o6N0 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Qr15WAAPiZA0o6N0 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Qr15WAAPiZA0o6N0 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Qr15WAAPiZA0o6N0 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Qr15WAAPiZA0o6N0 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Qr15WAAPiZA0o6N0 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Qr15WAAPiZA0o6N0 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Qr15WAAPiZA0o6N0 .cluster text{fill:#333;}#mermaid-svg-Qr15WAAPiZA0o6N0 .cluster span{color:#333;}#mermaid-svg-Qr15WAAPiZA0o6N0 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Qr15WAAPiZA0o6N0 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Qr15WAAPiZA0o6N0 rect.text{fill:none;stroke-width:0;}#mermaid-svg-Qr15WAAPiZA0o6N0 .icon-shape,#mermaid-svg-Qr15WAAPiZA0o6N0 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Qr15WAAPiZA0o6N0 .icon-shape p,#mermaid-svg-Qr15WAAPiZA0o6N0 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Qr15WAAPiZA0o6N0 .icon-shape .label rect,#mermaid-svg-Qr15WAAPiZA0o6N0 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Qr15WAAPiZA0o6N0 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Qr15WAAPiZA0o6N0 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Qr15WAAPiZA0o6N0 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} update ships
server rotates
KeyA → KeyB
App v1.0
pins: KeyA
App v1.1
pins: KeyA, KeyB
App v1.1
still validates,
now against KeyB
9. Limitations and Trade-offs
| Limitation | Detail |
|---|---|
| Not a silver bullet | On a jailbroken/rooted device, or in a debug build, tools like Frida/Objection can hook SecTrustEvaluateWithError or the delegate method directly and force it to always succeed --- this is exactly the workaround mentioned in the Charles doc's Section 11. Pinning raises the cost of interception; it does not make it impossible on a device the attacker fully controls. |
| Operational risk | Get certificate rotation wrong, and you lock out every user on an old app version until they update --- a self-inflicted outage. |
| No pinning on debug builds is common | Many teams #if DEBUG out the pinning check so QA can still use Charles/Proxyman during development, and only ship it enabled in Release builds. |
| CA-level pinning is looser | Pinning to your organization's root/intermediate CA (rather than the leaf) is less brittle across renewals, but also permits any certificate that CA later issues --- a smaller trust surface than "any public CA," but larger than "this one key." |
10. Summary
Certificate pinning does not add encryption strength --- TLS is already unbreakable by brute force. What it changes is the trust decision:
Normal HTTPS asks "did any CA the OS trusts vouch for this?" Pinning asks "is this the one specific certificate/key I already know belongs to my server?" --- and answers that question using a value baked into the app itself, bypassing the OS trust store entirely.
That is precisely the mechanism that makes a Charles-style MITM proxy --- which can only ever present its own key, regardless of which CA signs for it --- fail the check, even after its Root CA has been fully installed and trusted on the device.
| Scenario | Validation performed |
|---|---|
| Normal HTTPS | "Signed by a trusted CA?" --- Charles passes once its root cert is trusted |
| Certificate pinning | "Byte-identical to the pinned cert?" --- breaks on every cert renewal |
| Public key pinning | "SHA-256(public key) in the pinned set?" --- survives renewal, breaks on key rotation unless pre-provisioned |
| On a controlled device | Runtime hooking (Frida/Objection) can still bypass the check in the app's own code |