Pledgely logo Pledgely

Running Apple's PIR Service for NEURLFilter: Notes From a Production Deployment

4 min read · Updated July 28, 2026

NEURLFilter's client side is a few hundred lines of Swift. The server side is where the project actually lives: a Private Information Retrieval service, a Privacy Pass token issuer, an Oblivious HTTP gateway, and a blocklist pipeline that feeds all three. Apple ships an open-source reference implementation, and it gets you most of the way. Ours runs in production; these are our notes from taking it the rest of the way.

The four services and who talks to whom

  • PIR server. The core: answers encrypted "is this URL blocked?" queries using homomorphic encryption, without learning the URL. Apple's pir-service-example (Swift, from the same stack that powers Live Caller ID Lookup in iOS 18) implements the wire protocol iOS expects. You feed it a processed database built from your blocklist; it does the cryptography.
  • Privacy Pass issuer. Issues the anonymous tokens iOS attaches to PIR requests. This is your monetization gate: your app hands the device an authentication token, the device redeems it for unlinkable Privacy Pass tokens, and your server can enforce "paying users only" without ever being able to tie a lookup to an account. In Apple's example, the issuer runs in the same service as the PIR server.
  • OHTTP gateway. In production, devices don't connect to your PIR server directly; requests arrive through an Oblivious HTTP relay so you never see client IPs. You run the gateway side; it decapsulates and forwards to the PIR server over the internal network.
  • The prefilter (Bloom filter). Not a server at all: the OS asks your control provider for it on a configurable interval, and the provider returns the bit vector plus its parameters from wherever you choose, your backend or the app bundle. Either way, regenerate it in the same pipeline step that rebuilds the PIR database.

Serving is the cheap half: idle footprints are measured in tens of megabytes, and the whole backend runs comfortably on modest hardware. The real resource event is database generation, not serving. Processing a blocklist of hundreds of thousands of entries into PIR shards is a spiky, memory-hungry batch job; run it offline and treat the output as an artifact you ship to the server, not something you rebuild in place.

The gotchas that cost us time

1. Xcode builds and TestFlight builds take different network paths. Development installs query the PIR server directly; TestFlight and App Store builds go through OHTTP. Everything can work perfectly for weeks in development while your gateway config is broken. Stand up a TestFlight-path test as early as Apple lets you, and give the gateway a /health endpoint your uptime monitoring actually hits.

2. The blocklist pipeline is the actual product. The cryptography is Apple's; keeping the database, the Bloom filter, and reality in sync is yours. Our pipeline syncs the source blocklist, then rebuilds the PIR database and the prefilter from the same snapshot in one step, because they must describe the same list: a prefilter that's newer than the database yields lookups that miss, which in a fail-closed filter surfaces as user-visible weirdness. Apple's BloomFilterTool (in the SimpleURLFilter sample) makes same-snapshot the default: one URL list in, both artifacts out (bloom_filter.plist + input.txtpb). Feed it one URL per line, ASCII only (Punycode anything else), and skip www/path/query variants, since the filter fuzzy-matches those itself.

3. The usecase name is a contract, not a label. The PIR server's usecases/name must equal the bundle identifier of the connecting app plus .url.filtering. Get it wrong (a stale team-ID suffix, a copy-pasted sample value) and nothing tells you directly; queries just fail. It's the first thing to check when the filter won't reach running.

4. You can't curl the query path, and stale caches look like outages. PIR endpoints require Privacy Pass tokens only iOS can produce (the token exchange is Blind RSA, RFC 9578), so health checks have to probe the token-key endpoint and the gateway rather than a real query. And when the PIR server restarts, devices can hold stale parameters: the filter shows enabled, status sits at stopped, and Console shows NEVPNConnectionErrorDomainPlugin code 7. The client-side fixes are resetPIRCache() and refreshPIRParameters(); put them behind a debug menu before you need them.

5. Fail-closed makes your uptime someone's browsing. We chose fail-closed (shouldFailClosed) because a blocker that fails open has a built-in bypass. The price is that your PIR service is now infrastructure in the user's critical path. The Bloom filter absorbs almost all lookups locally, so steady-state server traffic is small, but "small" is not "optional". Boring ops discipline (monitoring, restarts, capacity headroom) is part of the filter.

6. Run it before you're approved. Nothing about the backend requires the url-filter-provider entitlement; only shipping the client does. Deploying ahead of approval means the operational learning curve is behind us, and the eventual swap is an Xcode change, not an infrastructure project. Approval timing is outside your control; the backend is not. Use the time.

Is it worth it?

Against a domain blocklist served over DNS, this stack is dramatically more work. What it buys is unique: system-wide URL-level filtering on stock iPhones where the operator provably cannot build a browsing log, enforced by cryptography rather than a privacy policy. For an adult-content blocker, whose users have every reason to distrust accountability software that watches them, that property is the whole reason to build it.

Start with how NEURLFilter fits together.

Next: NEURLFilter: how iOS 26's URL filter actually works

Put real stakes behind quitting

Pledgely blocks porn across your whole phone and charges your own pledge only if you turn the blocker off. Stay clean, pay nothing.