Family Sync With No Server: Apple-Signed Entitlements and MultipeerConnectivity
ReceiptIQ is a receipt-scanning expense tracker I build alone. Last year I added the feature users asked for most: share one subscription with your family, and keep everyone's receipts and budgets in sync. The obvious design is a backend — accounts, a database, sync endpoints. I shipped it with none of those. There is no server, no user account, and no copy of anyone's data outside their phones. This post is about how that works and what broke along the way.
Why no server
Three reasons. First, it's a finance app; the least interesting thing I could do with people's grocery receipts is hold them. Every privacy question disappears when the data physically never leaves the household. Second, I'm one person — a sync backend is an on-call rotation I'd be running alone forever. Third, it's a subscription app, and subscribers outlive servers: if I get hit by a bus, syncing between two phones on the same WiFi should keep working.
Problem one: proving someone paid, without a server
Family sharing means the owner's subscription must extend to family devices that never bought anything. With a backend you'd verify the receipt server-side and issue your own session tokens. Without one, the member device has to verify the owner's subscription itself, from data the owner's phone hands it — and that data must be unforgeable.
StoreKit 2 gives you exactly one thing that works here: VerificationResult.jwsRepresentation — the transaction as a JWS signed by Apple. The tempting alternative, Transaction.jsonRepresentation, is just JSON; anything on the wire could have written it. The JWS travels to the member device during pairing, and the member verifies it locally:
- Walk the
x5ccertificate chain in the JWS header with SecTrust, anchored to Apple's root CA (I bundleAppleRootCA-G3.ceras the sole pinned anchor). - Verify the ES256 signature over
header.payload— noting that JWS signatures are rawr‖s, not the DER encoding SecKey expects, so you convert before callingSecKeyVerifySignature. - Then check the payload: your bundle ID, your product IDs, not expired, not revoked, and
environment == Productionin release builds.
That last check has a fun consequence: TestFlight builds run in the Sandbox environment, so family pairing can never be tested through TestFlight — only debug builds accept Sandbox transactions. I learned that one the slow way.
Members re-verify the cached JWS on every launch, and the owner pushes a fresh one on every sync contact. If the owner cancels, every family device drops back to free once the cached proof expires — no server needed to enforce it, the math enforces it.
Problem two: pairing that a screenshot can't steal
Devices join by scanning a QR code on the owner's phone. The QR is deliberately boring: a family UUID and a one-time nonce, about 100 bytes. It contains no entitlement and grants nothing by itself — redeeming it requires a live MultipeerConnectivity handshake with the owner's device, on the same network, while the QR sheet is open, and the nonce dies after one use. Screenshot it, post it, nothing happens. The subscription proof travels only inside the encrypted session after the handshake authenticates with an HMAC over a family secret that lives in each device's Keychain.
Problem three: sync without a referee
With no server there's no authoritative timeline, so conflict resolution is last-writer-wins on a per-record modifiedAt, with tombstones for deletes. LWW is easy to describe and easy to get subtly wrong; these are the invariants that turned out to matter:
- Applied remote data keeps the sender's
modifiedAtverbatim. Re-stamping on apply makes the receiving copy look newer, which makes the next sync send it back, which re-stamps it again — an infinite ping-pong between two phones. This is the classic LWW bug and I wrote it the classic way first. - Clamp remote timestamps to
now + 5 minutes. One phone with a wrong clock can otherwise generate edits "from the future" that beat every legitimate edit for months. - Ties break deterministically (by device ID), so two devices can't each decide they won.
- Edit-after-delete beats the delete. If one person deletes a receipt while another is editing it, resurrecting the edit is the recoverable mistake; honoring the delete destroys someone's work.
- Tombstones replicate with their original date and purge after 90 days — long enough that every family device has heard about the delete, short enough that the table doesn't grow forever.
MultipeerConnectivity: the parts the docs don't emphasize
MC gives you discovery, sessions, and encryption for free, and then charges you in edge cases. What I'd tell past me:
- Keep the protocol strictly one-to-one. MC happily puts three family devices in one session, and a sync protocol that assumes one peer then corrupts state in ways that look random. I lock a single active peer per session and drop latecomers; they retry when the session frees up.
- Never send a large library at once. Receipt photos queue inside MC's send buffer; sending hundreds at once is an out-of-memory crash on the sender. Photos go in waves of twenty, next wave on completion of the last.
- Tear down with a handshake, not a disconnect. The fast device finishing first and dropping the session cuts the slow device's in-flight transfers. Both sides announce "done" and only then disconnect.
- Silence is the default failure mode. Miss
NSBonjourServicesin Info.plist and discovery just quietly finds nobody. Local Network permission denied? Also silence. Both are surfaced as explicit "check Settings" errors in the UI now.
The bug that shipped for two months
My favorite lesson came from my own second phone. Its "last synced" timestamp froze at a month old — while every sync visibly delivered all the receipts. The cause: when a device requests photos, the serving side silently skips any photo it can no longer provide (the receipt was deleted after the manifest was built, say). The protocol had no "unavailable" reply. The requester waited for a photo that would never come, its completion gate never opened, it never sent its final done — and a watchdog quietly failed a sync whose data had actually transferred fine. Every sync. For two months. The data always arrived, so nothing ever looked wrong.
The fix is a per-wave silence timeout on the requesting side: if no photo arrives for thirty seconds while some are still pending, treat the missing ones as unavailable and finish. That repairs the situation even when the other phone runs an old version — which is the constraint that makes protocol bugs in shipped apps genuinely fun: you can't fix the peer, only your own side's tolerance of it.
The general lesson is one I keep re-learning in different clothes: never treat an external party's silence as an answer. A peer that doesn't reply, an OCR pass that returns nothing, a StoreKit query that yields zero entitlements on airplane WiFi — every one of these once made the app confidently do the wrong thing until it learned to treat silence as "unknown," not "no."
Costs of this design, honestly
- Sync is foreground-only and same-network — both phones open, same WiFi. For a household expense app that turns out to be nearly fine; the "Sync Now" button plus an opportunistic pass at launch covers most real usage.
- Revocation is best-effort. A removed device that never reconnects keeps premium until its cached proof expires. With no server there's nobody to push the revocation through; the owner's device spreads a revocation list on contact instead.
- There's no web access and no cross-platform path — the data lives only on the phones. That's also the feature.
ReceiptIQ is on the App Store; family sharing covers up to four devices on one subscription. Happy to answer questions about any of this — the JWS verification and the LWW invariants are the parts I most wish someone had written up before I built them.