Onion-only, on-demand Tor routing in a mainstream macOS browser, how it works for me in my project, and where I'd like the community to tear it apart

Hello folks,

I’m sharing the Tor implementation details of Searxly, a privacy-focused native macOS browser (WebKit/WKWebView + local SearXNG). I’d genuinely appreciate the community’s eyes on the design, especially the parts I’m least confident about.

The design goal for what I did is the opposite of “route everything through Tor.” Clearnet browsing stays clearnet, direct, not anonymized, by design. Tor only ever spins up for .onion traffic, lazily, the first time you open one. Here’s exactly how it’s built, including the parts I’m least sure about.

So first, we have bundled Tor, supervised outside the sandbox

The app ships the official Tor expert bundle (the tor binary + geoip/geoip6 files) and runs it as a plain client, never a relay. The main app is sandboxed, so Tor is launched and supervised by a small privileged helper over XPC. The generated torrc is deliberately boring:

SocksPort 127.0.0.1:19050 IsolateDestAddr IsolateDestPort

ControlPort on localhost with CookieAuthentication 1

ClientOnly 1, AvoidDiskWrites 1, Log notice stdout

The SOCKS and control ports are intentionally off Tor’s defaults (not 9050/9150) so we never collide with a system Tor or Tor Browser the user already runs.

Bootstrap percentage is read from the notice log and shown as a “Connecting…” state. The control port (cookie-authenticated) feeds a small circuit view, entry > middle > exit, with relay nicknames and country flags via GETINFO circuit-status / ns/id / ip-to-country, and also a “new circuit” button issues SIGNAL NEWNYM. When Searxly launches, any stale Tor left over from a previous run is reaped, since onion tabs aren’t restored across launches.

And so with that, onion-only routing via a per-tab SOCKS proxy

This is the core mechanism. On macOS 14+, WebKit lets you attach a ProxyConfiguration to a per-tab WKWebsiteDataStore. So an onion tab gets its own ephemeral, non-persistent data store whose traffic is pinned to Tor’s SOCKS5 endpoint. Normal tabs have no proxy attached at all and are completely untouched, there’s no global “Tor mode” being flipped.

Because .onion names don’t exist in DNS, they can only resolve at the proxy, Tor does it. The whole onion-tab datastore is pinned to the SOCKS proxy, so there’s no local DNS lookup for the onion to leak; the fact that onions resolve at all is proof the resolution is happening inside Tor. A useful side effect of this structure: “is this an onion tab?” is simply “does its datastore carry a proxy config?”, no extra per-tab bookkeeping, and every leak guard below keys off that same single fact.

We made it so auto-connect only works when a .onion is detected

Tor is off by default. Detection is a one-liner (host == “onion” || host.hasSuffix(“.onion”)), and every navigation entry point, address bar, search results, history, bookmarks, restored sessions, programmatic loads, command-clicks (on mac), funnels any .onion into one dedicated path instead of the normal loader. That path:

On the first onion ever, I made it so it shows a one-time consent sheet (what Tor protects here, and bluntly what it does not).

Validates the v3 address (56 base32 chars) up front, so typos and dead v2 addresses fail fast with a clear message instead of a long opaque timeout.

Shows a local “Connecting to Tor…” placeholder (no network) while the circuit bootstraps — with the real .onion already shown in the address bar.

Issues the actual request only after Tor reports a ready circuit. There’s no window where the first request could race ahead of the proxy.

When the last onion tab closes, Tor is stopped, resource and privacy hygiene.

Also detecting that a clearnet site has a .onion (Onion-Location)

This follows the Tor Project’s Onion-Location spec, both delivery methods:

the Onion-Location HTTP response header on the main frame, and

the tag (checked via JS after load) as a fallback.

A detection on a normal tab surfaces a slim, dismissible “This site offers a Tor version > Open in Tor” banner; accepting hands the .onion to the same path. The banner is tied to the on-screen host, so it auto-hides the moment you navigate away. Both checks are skipped entirely on onion tabs themselves.

One pragmatic addition, because a lot of big sites still advertise an onion they’ve since abandoned and you can’t know an onion is dead without connecting: if an advertised onion fails to load once, I remember that host and stop auto-offering it. The mechanism is deliberately simple and fully local,

When an onion tab’s load genuinely fails, the navigation delegate fires. It first filters out NSURLErrorCancelled, that one isn’t a real failure, it’s just the “Connecting to Tor…” placeholder being swapped for the real request, so only true connection failures count.

On a real failure it shows a “can’t reach this hidden service” page and records the onion’s host in a small persisted set (a plain Set in UserDefaults, keyed by host), so the suppression survives relaunches.

The Onion-Location detector checks that set before ever raising a banner; a known-dead host produces no offer, and any banner already on screen for that host is cleared the instant the failure comes in.

It’s intentionally one-strike-and-silent: liveness genuinely can’t be probed without connecting, so the cheapest honest signal is “we tried, it didn’t answer, stop suggesting it.” IMPORTANT though: iit never blocks the onion. Manual navigation and reload still work, so if brings it back, it loads fine; it just won’t be auto-offered again.

We have leak hardening on onion tabs

Routing alone isn’t enough, so onion tabs also get:

A scheme allow-list: only http(s)/about/data/blob are permitted; custom schemes, file:, etc. are cancelled so a navigation can’t slip off the proxied path.

No command-click escape: opening a link in a new standard (non-Tor) tab is disabled on onion tabs, since that would leave Tor and deanonymize the request.

WebRTC and geolocation neutralized at document start, in every frame, the WKWebView IP-leak vectors. Also being completely honest, this is a JavaScript mitigation injected via a user script at document-start, i.e. defense-in-depth on top of the network routing, not a platform-level guarantee. If you can think of a way an early script, worker, or WebKit quirk defeats it, I want to hear it.

A neutral User-Agent: onion tabs drop the app’s UA suffix so they don’t stand out as “Searxly private.”

What it deliberately does NOT do

No Tor Browser-level fingerprinting defense, no letterboxing, no font/canvas/timer normalization. It hides your IP for onion traffic and reaches hidden services — it does not make you anonymous against a determined, fingerprinting adversary. That line is stated plainly in-app, on purpose, rather than buried.

Known limitations / what I’d most like you to scrutinize

Being straight about the weak spots, because that’s where your feedback is worth the most:

WKWebView fingerprint surface. It’s WebKit, not the Tor Browser engine. Two users are distinguishable. I treat this as a known, stated limitation, not something I claim to solve.

Stream isolation granularity. Today it’s IsolateDestAddr IsolateDestPort on a single shared SOCKS port. Different destinations get different circuits, but I do not do per-tab isolation (e.g. IsolateSOCKSAuth with per-tab credentials). Is per-tab/per-identity isolation worth adding here, or is dest-based isolation sufficient for this use case?

The JS-based WebRTC/geo block, is document-start user-script injection a reliable place to neuter these in WKWebView, or are there earlier hooks I’m missing?

One-strike dead-onion suppression, a transient outage demotes an onion from auto-offers (never blocks it). Would a TTL/expiry be worth the added state, or is “manual still works” good enough?

Clearnet is intentionally not Tor-routed. I want to be explicit so no one assumes normal browsing is anonymized, it isn’t, and it’s not meant to be.

If you wanna check the tor work yourself, just saying:

It’s source-available (not OSI-open) under PolyForm Noncommercial, I’d rather state that plainly than have it discovered. Happy to walk through any of the routing code referenced above if it helps a reviewer. GitHub - Searxly/Searxly-source-code: Privacy-respecting native macOS browser with a fully local SearXNG instance. Source-available (PolyForm Noncommercial 1.0.0). · GitHub

Thanks for reading.

Good day!