Mobile App Certificate Pinning: Underlying Principle and a Swift Example

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

  1. [The Trust Gap That Pinning Closes](#The Trust Gap That Pinning Closes)
  2. [What "Pinning" Actually Means](#What “Pinning” Actually Means)
  3. [Two Pinning Strategies: Certificate vs Public Key](#Two Pinning Strategies: Certificate vs Public Key)
  4. [Where Pinning Hooks Into the TLS Handshake](#Where Pinning Hooks Into the TLS Handshake)
  5. [Why This Blocks Charles/MITM Proxies](#Why This Blocks Charles/MITM Proxies)
  6. [Swift Example: Public Key Pinning with URLSession](#Swift Example: Public Key Pinning with URLSession)
  7. [Swift Example: Certificate Pinning](#Swift Example: Certificate Pinning)
  8. [Handling Certificate Rotation](#Handling Certificate Rotation)
  9. [Limitations and Trade-offs](#Limitations and Trade-offs)
  10. 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:

  1. Client sends CONNECT api.example.com:443 to Charles.
  2. Charles mints a certificate: Subject: api.example.com, Issuer: Charles Root CA.
  3. Normal HTTPS: OS checks "is Charles Root CA trusted?" → yes (user installed it) → accepted.
  4. 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:

  • SecTrustEvaluateWithError still 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:

  1. Pin the public key, and pre-provision the next key before rotating, as shown in Section 6's pinnedPublicKeyHashes set. Since the same key pair can be reused across a certificate renewal, this alone avoids most routine renewal breakage.
  2. 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
相关推荐
韩曙亮1 小时前
【Flutter】iOS 网络权限设置 ② ( 配置 iOS 平台可使用 HTTP 访问 | ATS 网络安全强制规范 )
网络·flutter·http·ios·https·网络权限·ats
阿豪只会阿巴1 小时前
两小时快速入门 FastAPI--第一回
开发语言·python·fastapi
你怎么知道我是队长1 小时前
JavaScript 事件介绍
开发语言·前端·javascript
CodeStats2 小时前
【Linux IO】从文件描述符到硬件中断:Linux IO 系统底层完全拆解
java·linux·开发语言·io·socket
着迷不白2 小时前
Linux系统故障修复、日志管理与安全加固实战指南
开发语言·php
Chengbei112 小时前
VMware Workstation Pro 26H1免费虚拟化利器
运维·安全·web安全·自动化·系统安全·apache·安全架构
贺国亚2 小时前
A2A协议与Agent互操作-Task生命周期
开发语言·qt
炸薯条!2 小时前
从零开始学C++(4) --类和对象
开发语言·c++·算法
渡我白衣2 小时前
打印宏与socket模块设计
java·linux·开发语言·c++·ide·人工智能·eclipse