DEV Community

Cover image for How do you give local content in a WKWebView a real https origin? (iOS 17)
Pierre-Laurent Medori for GoodBarber

Posted on

How do you give local content in a WKWebView a real https origin? (iOS 17)

On September 16 I opened a test widget on my iPhone. At the top, a quote that loads when the widget opens. Under it, two text areas and a Compare button that returns a similarity score. Two calls to the same third-party API with the same key: a GET for the quote, a POST for the score.

The quote appeared. The Compare button answered with the only sentence its code can say when that call fails: "Impossible de calculer la similarité. Veuillez vérifier votre connexion." Unable to compute the similarity, please check your connection. The connection was fine, my Android test phone did exactly the same, and in a browser both calls worked.

GET works, POST fails. I spent a morning on the verb. The cause was the page's origin, and the GET had never worked.

This piece is about that origin: where a page in a WKWebView gets one when its files live on the device, how our iOS engine now serves them from a fixed https://secure.internal with a proxy API that arrived in iOS 17, and what changing an origin breaks in places no crash log reaches. The widget comes back at the end, once the dates make sense.

Why local content needs an origin at all

At GoodBarber, an app builder for native iOS and Android apps, some sections of an app are web content: custom code written by the app's owner, HTML widgets, extensions. The files live on the device so that the section opens offline. On iOS they are displayed in a WKWebView.

How those files are served decides whether the page is a secure context, a prerequisite for APIs such as crypto.subtle. It also sets the origin seen by CORS and CSP checks, cookie behaviour, and where localStorage and IndexedDB data are stored.

One of those checks is ours. My team is piloting an extension builder, where I look after the backend: the owner of an app asks for a feature in a sentence, a model writes it, and the result runs in the app's web version and inside its native apps. A widget that needs a private API key never holds it. The page frames a small page served by our token issuer, gets a short-lived token, and calls a proxy of ours that keeps the key. The issuer lets only approved origins frame its page, through a CSP frame-ancestors directive. So the origin a native shell gives its local files decides whether any of those widgets can work at all. The builder has not shipped to customers, and the phones in this story are my own test devices.

Change the serving mechanism and all of that changes with it. We went through three versions in one year.

Three origins in one year

Period How the page was served Origin What it cost
before June 2026 an HTML string with a custom base URL, files through a WKURLSchemeHandler a custom scheme not a secure context, an Origin header that third-party servers do not recognise, Secure cookies ignored, URL rewriting and a permissive CSP to hold it together
June to August 2026 a small HTTP server inside the app, bound to loopback http://127.0.0.1:<port> a secure context and plain HTTP semantics, but an IP address and a changing port in the origin
since September 2, 2026 a loopback CONNECT proxy in front of a TLS listener https://secure.internal iOS 17 minimum for this approach, and web storage starting from empty once

The second step was already a big one, and our iOS team built it as a real server: request parser, range requests for media, keep-alive, recovery when the listener dies. It also relays the page's cross-origin fetch calls, which the shell rewrites towards it. Loopback is a potentially trustworthy origin, so the page became a secure context and third-party scripts finally saw an http origin they understood.

The remaining problem was the port, assigned at runtime. There was no single origin to put in a remote allowlist. On July 30 the token issuer started accepting http://127.0.0.1:*, which is valid CSP and accepts any port, including any unrelated local server on the phone. A fixed https name would give the app and those services a value they could agree on.

WebKit will not hand over https

Apple's documentation for setURLSchemeHandler(_:forURLScheme:) rules out the direct approach: registering a handler for a scheme WebKit already handles, such as https, raises an exception.

Android takes the opposite position. shouldInterceptRequest lets an app answer any request, https included, and WebViewAssetLoader packages the pattern with a domain reserved for it, appassets.androidplatform.net. Our Android engine moved from file:// to that loader on July 31, a fixed https origin came with it, and the issuer accepted it the same day.

That Android approach does not transfer to WKURLSchemeHandler.

Why not a real certificate for a real domain?

The tempting shortcut is a public hostname that resolves to 127.0.0.1, a certificate from a public authority, and the private key shipped in the app. Let's Encrypt has a page about certificates for localhost whose answer is: don't. A private key distributed in an app is a compromised key, the certificate may be revoked, and the DNS lookup gives an attacker something to intercept. It also makes an offline section depend on DNS.

We could still use a certificate whose trust was limited to our own WebView. It did not need recognition from a public authority.

The piece that arrived with iOS 17

iOS 17 added proxyConfigurations to WKWebsiteDataStore, fed by the Network framework's ProxyConfiguration. Apple presented it at WWDC23 for network relays, with a remote relay in the example. Nothing says the proxy has to be remote.

import Network
import WebKit

// The CONNECT proxy is a listener our own app opened on loopback.
let proxy = NWEndpoint.hostPort(host: "127.0.0.1",
                                port: NWEndpoint.Port(rawValue: connectProxyPort)!)

var configuration = ProxyConfiguration(httpCONNECTProxy: proxy, tlsOptions: nil)
configuration.matchDomains = ["secure.internal"]  // only this name uses the proxy
configuration.allowFailover = false               // never try a direct connection

WKWebsiteDataStore.default().proxyConfigurations = [configuration]
Enter fullscreen mode Exit fullscreen mode

Those two properties carry the design. With matchDomains, WebKit sends CONNECT secure.internal:443 to our listener instead of resolving the name, so the name never reaches DNS and the section opens in airplane mode. With allowFailover off, WebKit never falls back to a direct connection, so the name cannot leak to a resolver while our server is restarting.

Inside the app, three loopback listeners do the work:

  • A TLS listener serves the files. Its identity is embedded in the app: self-signed, RSA 2048, a single SAN secure.internal, TLS 1.2 minimum, ALPN http/1.1.
  • A CONNECT proxy on a port the OS assigns. It accepts exactly one request, CONNECT secure.internal:443, and pipes the bytes to the TLS listener. Anything else gets a 403.
  • A plain HTTP fallback for media, in case the media stack does not follow the data store's proxy.

The configuration is pushed again every time the server restarts, since the port changes, and before the WebViews are told to reload.

Accept one certificate, for one host

A self-signed certificate fails the system's trust evaluation. That is expected, and the navigation delegate is where it is settled. Ours is Objective-C. Here is the same logic in Swift:

func webView(_ webView: WKWebView,
             didReceive challenge: URLAuthenticationChallenge,
             completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
    let space = challenge.protectionSpace
    guard space.authenticationMethod == NSURLAuthenticationMethodServerTrust,
          space.host.caseInsensitiveCompare("secure.internal") == .orderedSame,
          let trust = space.serverTrust else {
        completionHandler(.performDefaultHandling, nil)  // every other host: the normal path
        return
    }
    let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate]
    let leaf = chain?.first.map { SecCertificateCopyData($0) as Data }

    if leaf == pinnedCertificateDER {                     // byte-identical to the embedded one
        completionHandler(.useCredential, URLCredential(trust: trust))
    } else {
        completionHandler(.cancelAuthenticationChallenge, nil)
    }
}
Enter fullscreen mode Exit fullscreen mode

The exception applies only to that host and that exact certificate. Every other host follows normal trust evaluation.

The embedded key is shared across our apps and can be extracted from a binary. We treat it accordingly. The design rests on the local routing and on the delegate's narrow trust decision; it does not give the certificate public authority or make it an app identity.

One more gate sits in front of the files. Loopback listeners are reachable by other processes on the device, so every URL starts with a random token generated for the session, and a request without it gets a 403.

Choosing the name

Since the name never touches DNS, it is cosmetic. It shows up in location.origin, in the web inspector and in server-side allowlists, never in front of a user. Two constraints still apply: the suffix must never be delegated on the public DNS, and no certificate authority must ever issue for it.

That rules out .app, which is a real top-level domain, and .local, which belongs to multicast DNS and answers on the local network. It points at .internal, which the ICANN Board reserved for private-use applications on July 29, 2024, permanently excluding it from delegation in the DNS root zone.

What we measured

The new engine landed on September 2, and the issuer swapped http://127.0.0.1:* for https://secure.internal the same day. These are the September 4 verification runs: iPhone 15 Pro simulator, iOS 17.4, our internal app in Debug, two real customer apps loaded in it.

Check Result
Page loads logging location.origin and isSecureContext 43 loads across plugin sections, HTML widgets and scratch sections, 43 times https://secure.internal and true
crypto.subtle present
TLS handshake through the tunnel TLS 1.3, 6 ms
Import of the embedded identity 3.7 to 4.0 ms, measured four times
Live CONNECT tunnels peak of 1, back to 0 after each cycle
Native bridge, storage round trip, cross-origin GET and POST relays exercised on 9 scopes towards 3 remote hosts
Backgrounding for 50 seconds, then return the WebView survives and the appear event fires again
Video rendering not testable on the simulator, whose WebKit GPU process is denied GPU access; normal on a device

What the https origin changed

Mixed content appeared. Old custom code links http://fonts.googleapis.com/… stylesheets, in dozens of HTML files across 12 sections of a single test app. Under an http origin nobody noticed. Under https, WebKit blocks them as active mixed content and the page silently loses its fonts. The server now adds Content-Security-Policy: upgrade-insecure-requests to HTML documents, and to nothing else. Our Android engine met the same files after its own migration.

Tunnels have to die on the first EOF. A 28-minute capture showed 68 CONNECT tunnels alive at the same instant, all killed together with ENETDOWN when the app was suspended. Our relay waited for both directions to finish before closing, and WebKit keeps its side open and idle. A CONNECT tunnel is done as soon as either peer stops writing. With that rule, and a counter logged on every open and close, the peak is 1. ENETDOWN on suspension is now logged as a routine end of life, because it is one.

Web storage starts from empty. localStorage, IndexedDB and cookies are filed by origin, and WebKit offers no supported way to move them from one origin to another: WKWebsiteDataStore can enumerate and delete by origin, not read or rewrite. A custom section that kept a login token asks for the login once after the update. That is the visible price of an origin change.

The fourth change has no log line at all. It is the widget from the first paragraph.

Back to September 16

The phones ran internal builds of our shells from early summer. That morning I built both engines on my laptop and ran the same widget on fresh installs.

Surface Native shell GET, the quote POST, the score
Web none, a browser ok ok
iOS simulator built that morning ok ok
Android emulator built that morning ok ok, 48%, no console error
Test iPhone internal build, early summer a quote was displayed error message
Test Android phone internal build, early summer a quote was displayed error message

Same bundle, same backend, same key. The only variable left was the shell installed on the phones. Read the last two rows again: I wrote "a quote was displayed", not "ok". That morning I wrote "ok".

The verb was a good suspect, because a WebView does treat a request with a body differently. Browsers attach an Origin header to a same-origin POST and not to a same-origin GET, as the Fetch standard specifies, so any guard that compares Origin to an expected value is only ever exercised by POSTs. On Android, shouldInterceptRequest hands the app a WebResourceRequest with a URL, a method and headers, and no body. On iOS, our relay has to buffer the whole body before forwarding anything, and a GET has nothing to buffer. One fact should have cooled me down: both calls carried a bearer token in an Authorization header, which makes both of them preflighted cross-origin requests. For CORS, my GET and my POST were in the same class.

The coding agent I was pairing with asked for one trace from the phone, Safari's Web Inspector or chrome://inspect. I chose to build instead, because a build feels like progress. It worked, and this went into my notes: an outdated app relays the GETs and loses the body of the POSTs; check the build date before you look at the server. The second half is good advice. The first half is a mechanism nobody observed. The fix was right, the cause was invented.

Two days later I asked the same agent to draft an article from that session. Before writing the mechanism down for strangers, it checked it against git history, and the dates in this article do not allow it.

  • The iOS relay has buffered the complete body since the day it was written, June 4. There is no older relay that drops bodies for a phone to be outdated with.
  • On September 2 the issuer stopped accepting http://127.0.0.1:*. My commit message gives the reason: the feature is unreleased, and an app is rebuilt when the feature is activated for it. An iPhone build older than September 2 can no longer frame the token page. No frame, no token. No token, no call, whatever the verb.
  • Android builds older than July 31 load the page from file://. A file:// page has an opaque origin, and an opaque origin cannot be listed in frame-ancestors. No token there either.

So on those phones the GET could not have worked. Yet both showed a quote. The simulator still had the bundle it downloaded on the 16th, with the function that loads the quote, as the model wrote it (key name and author line shortened):

gbProxyFetch(KEY, "/v1/quotes")
  .then(function (res) {
    if (!res.ok) { throw new Error("Erreur API"); }
    return res.json();
  })
  .then(function (quotes) { /* render quotes[0] */ })
  .catch(function (err) {
    document.getElementById("quote-text").textContent =
      "« La simplicité est la sophistication suprême. »";
    /* ...and "Léonard de Vinci" in quote-author */
  })
Enter fullscreen mode Exit fullscreen mode

On a shell that cannot frame the token page, the token request fails, the promise rejects, and this catch prints Leonardo da Vinci. That is what both phones were showing: Leonardo, in French. The POST went through the same failure with nothing to fall back on, so it told me to check my connection.

The asymmetry was not between two HTTP verbs. It was between two catch blocks. Nothing had worked on the phones; one of the two failures was dressed as a success. The evidence was on the screen: the API returns its quotes in English, the fallback is in French. And the SDK rejects with a typed error whose kind names the stage that failed, init when no token could be obtained. Both catch blocks received it. Neither read it.

An origin is versioned by the binary

A feature that lives in a web page inherits the page's origin, and inside a native app the shell chooses it: file://, a loopback port, a custom scheme, a fixed https name. CORS, CSP frame-ancestors, cookies and every server-side allowlist key on it. So every allowlist that names an origin is, in practice, a list of builds, and the builds are installed on people's phones on dates you do not control.

The switch of September 2 was a deliberate decision, and it holds: a rebuilt app carries the current shell, the current shell serves the origin the issuer expects, and the two move together. An app owner never meets the old origin. That works because the platform owns both ends, the native shell and the services behind it, which is also why every date in this article is known to the day. The one way to meet the old origin was to test an unreleased feature on an internal build from early summer, on the phone of the person who made the switch.

On the company blog I wrote about what quietly breaks when an app is not updated: store visibility, push delivery, OS changes. This is the same decay seen from the server side. An allowlist moves, and every build older than that date loses a capability, without a crash and without a line in the phone's log.

What I check first now

  • Is the half that works real? Before comparing a GET and a POST, find out whether the GET's data came from the network. Search the page for catch. If a model wrote the code, expect a fallback: models are polite about failure, they fill the hole.
  • One trace from the failing device. Web Inspector or chrome://inspect, five minutes. It would have shown no request to the proxy at all, neither GET nor POST, and a refused frame in the console.
  • The build date of the shell, before any server log, whenever the report says "fine on the web, broken in the app". Then compare it with the dates your allowlists changed.
  • Substitution, last. The same bundle on a fresh shell proves where the fault is not, and says nothing about what happened on the old one. "Works on the simulator I built this morning" and "works on the phone in my pocket" are different claims. That gap is where a correct fix picked up an invented cause.

The model's fallback stays. A quote widget that shows a default quote instead of a broken box is the right behaviour for the person using the app; it is only a trap for the person debugging it. So its firing becomes visible:

.catch(function (err) {
  console.warn("[quote] proxy call failed:", err.kind, err.message);   // the line that was missing
  showFallbackQuote();
})
Enter fullscreen mode Exit fullscreen mode

With that line, the first look at the console says init, no token, before anyone opens Xcode. Degrade gracefully for the user, loudly for the developer.

After the migration

https://secure.internal is shared across our apps, just as appassets.androidplatform.net is shared by Android apps using that loader. It gives browser policies a stable value to compare. Authentication still needs its own mechanism, which is what the token is for.

The practical gain is that local content gets ordinary HTTPS behaviour while staying available offline. The cost sits in everything attached to the previous origin: storage, remote allowlists, and the builds already installed. That is why the new name is boring, fixed, and meant to stay. Changing it later is another migration, even if every HTML file stays the same, and the phones in people's pockets will not follow.

Top comments (0)