← Engineering

Pairing an Apple TV without a server

How Composite Sky connects an Apple TV to its owner's WeatherFlow Tempest account: OAuth 2.0 with PKCE, where the TV publishes a short-lived hostname on the local network and catches its own redirect. Nothing of ours sits between the customer and WeatherFlow.

0 Backend services
48 bytes PKCE verifier
30 s Hostname TTL
8815–8817 Candidate ports
1 Cleartext hop

Section 1What this solves

A Tempest weather station's data sits behind its owner's WeatherFlow account, so before the app can show anyone their own backyard weather, it needs an access token for that account. The first version shipped with the most direct answer available: bring your own API key.

It worked, and it was clunky. You signed in to WeatherFlow on a phone or a laptop, found the token, and then typed it into an Apple TV with a remote. Tokens are long, opaque, and unforgiving of a single wrong character. The field is a secure text field because the alternative is a credential sitting in plain view on a screen the whole room can see. Plenty of people got through it. It is not something you would choose to put in front of someone.

So the question became: what does the friendly version look like, without making the security worse? On a phone this is a solved problem. You tap a button, a web view opens, you sign in to WeatherFlow, and the app receives a token without ever seeing your password. That is ordinary OAuth, and it is genuinely better than typing a key, because the app never handles the credential at all.

On a television, the obvious route to that is closed. tvOS has no WKWebView. There is no way for the app to render WeatherFlow's sign-in page, which means the consent step has to happen on a device that does have a browser. In practice that is the phone already in the room. The design problem turns into a delivery problem: once the phone has finished signing in, how does the resulting authorization code get back to the TV?

The conventional answer is to point the redirect at a server the developer runs, which catches the code and relays it to the device. We did not want to be in that path. This sign-in is between the customer and WeatherFlow, about the customer's own weather station, and inserting our infrastructure into the middle of it means standing between two parties who have no need of a third. If that can be avoided, it should be.

So we looked for a way to keep the redirect entirely inside the customer's home. That is what the rest of this note describes: the redirect points at the Apple TV itself, which means the phone's browser needs a name it can resolve for a device that has no DNS name, and a listener waiting behind it.

Section 2The shape of the trick

The TV publishes an mDNS A record for compositesky.local pointing at its own IPv4 address, binds a plain HTTP listener on a known port, and puts the resulting authorize URL on screen as a QR code. The phone scans it, consents at WeatherFlow, and is redirected to http://compositesky.local:8815/callback?code=…. That request resolves over multicast DNS to the TV sitting in the same room, and the TV's own listener catches it. The hostname exists only for the length of the pairing window.

The verifier half of the PKCE pair is generated on the TV and never leaves it. That is what makes the plaintext hop survivable, and section 5 works through why.

The orchestration is a single pairing-session type: three steps to start, then a wait for a matching callback. The ordering in the start step matters: the hostname is published before the port is bound, and if the bind fails the hostname is withdrawn again before the error propagates. There is no window in which a name is being defended for a listener that does not exist.

The PKCE material is one value type: a 48-byte random verifier, its base64url SHA-256 challenge, and a 16-byte random state, all drawn from SecRandomCopyBytes. Only the challenge and the state go into the authorize URL.

The QR is rendered with Core Image's QR generator at correction level “M”: higher correction adds modules and shrinks each one, which costs scanning range, and range is what matters when the phone is on a couch and the QR is on a TV across the room.

Section 3The ephemeral hostname

The mDNS host registers the A record directly with mDNSResponder rather than going through NWListener's Bonjour service advertisement, because what is needed is a hostname that resolves, not a service anyone browses for. That is DNSServiceCreateConnection followed by DNSServiceRegisterRecord.

Details that matter:

  • kDNSServiceFlagsUnique: the record is registered as the unique owner of the name, so the daemon probes the network for a conflicting claim before confirming. On a quiet network that probe takes roughly 750 ms.
  • TTL 30 s, publish timeout 5 s: both are defaults on publish. The TTL is deliberately short because withdrawal is not instant. See section 7.
  • en0 is preferred for the address. The address lookup walks the interface list and returns en0 if it is up and non-loopback, otherwise the first other non-loopback IPv4 interface it finds. On an Apple TV en0 is the Wi-Fi or Ethernet interface.
  • Everything is driven synchronously on the calling thread: a poll(2) on the daemon socket plus DNSServiceProcessResult. No dnssd callback ever runs concurrently with anything else, which is why the callback context objects need no locking.

Because that pump blocks, the publisher runs the whole publish inside a detached task and only touches shared state for the pointer swap afterwards. The lock is never held across the blocking registration call. It also carries a tombstone for the race where a withdrawal lands while the detached task is still probing: the post-await swap discards the freshly-minted host instead of storing it.

Local network privacy

Publishing these records and accepting inbound connections on tvOS is not gated behind the NSLocalNetworkUsageDescription prompt, and the app correctly declares neither that key nor NSBonjourServices. The platform support table in Apple's TN3179, Understanding local network privacy, has tvOS as unsupported, with no version at which it was introduced. tvOS does not implement local network privacy at all. The one caveat worth knowing is that a platform can acquire it later: macOS only gained local network privacy in macOS 15.

Section 4Catching the redirect

The callback server is an NWListener wrapping the smallest HTTP server that will do the job.

Port fallback

It tries 8815, then 8816, then 8817, taking the first that binds. All three are registered as redirect URIs with WeatherFlow, because WeatherFlow matches both host and port exactly. There is no wildcard to fall back on, so each candidate port needs its own registration. The port that actually binds is what builds redirect_uri, and the same string is sent again in the token exchange, as the OAuth spec requires.

Binding is async for a non-obvious reason: NWListener reports address-in-use asynchronously, through a .failed state transition rather than a throwing initializer, so a bind attempt has to await the first ready-or-failed transition before it knows whether it won the port.

The three-way classifier

Every request head is sorted into exactly one of three kinds.

Kind Matches Page served Ends the window?
.callback GET /callback with a non-empty code paired Yes: yields on the stream
.denied GET /callback with an error param and no usable code not paired No
.other Anything else bland No

Two things about that table are deliberate and easy to get backwards.

  • A usable code beats an error param. The code check runs first and only falls through to the error check if no non-empty code was present. A redirect carrying both still pairs.
  • Only .callback ends the pairing window. The other two are answered with a page and nothing else: no yield on the stream. A favicon fetch from the phone's browser, a stray probe from something else on the LAN, or the user declining at WeatherFlow all leave the window running, because the TV is still showing the QR and the user may still be about to succeed.

The three pages are entirely self-contained (no <link>, no <script>, no remote src) because the listener is ephemeral and on a local network, and a page reaching for a CDN would make the last step of pairing fail for reasons unrelated to pairing.

Then the state check

A yielded callback still has to match the session's state before it counts. A mismatch is skipped and the loop keeps waiting. A stray or replayed redirect must not be able to end a live pairing window. WeatherFlow echoes state verbatim, which was measured rather than assumed.

Section 5Why plaintext HTTP is not the hole it looks like

The redirect URI really is http://, not https://. The authorization code crosses the LAN in the clear, in a URL query string, where anything else on that network can read it. That is a real property of this design, not an oversight, and it is worth being precise about what it does and does not cost.

HTTPS was never on the table. A TLS listener needs a certificate for compositesky.local, and there is no way to obtain a legitimate one for a name on someone else's Apple TV. RFC 6762 reserves .local for multicast DNS precisely because those names are local, not global, so no certificate authority can vouch for one. The only alternative, a self-signed certificate, puts a full-page browser warning in front of the last step of pairing, which is worse for the user than the thing it would be protecting.

So plain HTTP on the local network is what the platform gives us to work with. The question is what to build on top of it, and the answer is PKCE.

Concretely: the TV generates 48 random bytes and sends only their SHA-256 hash to WeatherFlow, inside the authorize URL. WeatherFlow stores that challenge against the code it issues. Redeeming the code at the token endpoint requires presenting the preimage (the raw verifier), which is sent once, over HTTPS, from the TV. An attacker who reads the code off the LAN holds one half of a pair whose other half was never transmitted in the clear and would have to be brute-forced out of a 384-bit space. WeatherFlow answers such an attempt with 400 or 401 and issues no token. (When the app receives that same 400 or 401, for a code that was already used, or a genuinely bad one, it maps it to a “token rejected” error. An attacker is not running the app, so that mapping is the app's own error handling and nothing more.)

Being honest about what remains

  • The code is visible, and codes are single-use: WeatherFlow returns 400 or 401 for a code that has already been redeemed. An attacker on the LAN can therefore race the TV to redeem it, but there is nothing to win, because redemption requires the verifier either way. The realistic damage is denial of service: an attacker who floods the listener or sabotages the redirect can stop pairing from completing. They cannot make it complete into their own account, and they cannot obtain the user's token.
  • The attacker must already be on the LAN. The listener is bound for the length of one pairing window and sorts every request it can read into one of three shapes, each answered with a fixed HTTP/1.1 200 OK carrying static HTML and no dynamic body. There is no persistent surface.
  • This is the LAN's threat model, not the internet's. An attacker with the ability to sniff this hop is an attacker already inside the user's home network, next to a TV.
Active attackers

The list above models a passive eavesdropper. An active attacker on the same LAN has two more things to try, and a third attack needs no network access at all. None of the three yields the user's token, but they are worth naming rather than leaving implicit.

  • Squatting the hostname. An attacker who claims compositesky.local before the TV does sends the phone's redirect to themselves instead. The kDNSServiceFlagsUnique registration in section 3 means the TV probes for a conflicting claim and fails to publish rather than silently sharing the name, so the visible effect is a “hostname unavailable” failure and a fallback to manual entry. If the squat succeeds, the squatter ends up holding an auth code whose verifier they do not have, the same dead end as the passive case.
  • Injecting a code. Because the redirect is plaintext, a LAN attacker learns the state value along with the code, and can send the TV's own listener a GET /callback carrying their own auth code and the sniffed state. It passes the classifier and the state check and the TV will try to redeem it: against its verifier, not the attacker's, so WeatherFlow rejects the mismatch and pairing fails. The TV cannot be induced to pair with the attacker's account this way. state is doing CSRF-style binding here, not confidentiality. This write-up never claims otherwise, and a reader should know the attacker has it.
  • Scanning the QR. Anyone who can see the TV screen (or a photo or screen-share of it) can scan the authorize URL and consent with their own WeatherFlow account. The TV then pairs successfully to a stranger's station and starts showing the wrong weather. No token of the user's is exposed and the fix is to disconnect and re-pair, but this is the one attack that needs neither LAN access nor any cryptography, and it is the first thing a reader looking at a QR on a television will think of.

The two hops that carry secrets (the consent flow on the phone and the token exchange from the TV) are both HTTPS to WeatherFlow. The token response body carries the access token, which is why nothing in the exchanger may log a request or response body, and the token lands in the Keychain, never in UserDefaults.

Section 6When it goes wrong

Failures split into two groups, and the split is the interesting part: some end the pairing window and drop the user into manual token entry with a one-line hint, while others deliberately leave the window running because the TV is still showing a scannable QR and the user has lost nothing.

There are exactly three pairing failure cases. hostnameUnavailable is thrown when publish fails for any reason: the underlying mDNS failure is flattened away, because from the setup screen's point of view a service error and a timeout lead to the same place. portsBusy comes from exhausting the port list. listenerClosed is what the await step throws if the stream finishes without a matching callback.

The hint lines differ by phase: a start-up failure gives “Pairing unavailable — enter a token instead”, a failure while awaiting the callback gives “Pairing failed — enter a token instead”. Choosing manual entry deliberately sets no hint at all: the user made that choice, so there is nothing to explain.

Measured, not anticipated

One failure mode is handled after pairing rather than during it: WeatherFlow returns a transient HTTP 429 on its stations endpoint immediately after a token is created. The model retries up to 3 attempts with a 2 s wait. Without it, a successful pairing would land on the “could not reach Tempest” screen.

Section 7Teardown

One cancel() method stops the listener and withdraws the hostname. Every teardown path goes through it:

  • Success: before the token exchange
  • Pairing start-up failed
  • The await step failed
  • User chose manual entry
  • Screen exit
  • A newer session supersedes this one

The success path is worth calling out because the ordering is deliberate and looks wrong at first glance: cancel() runs before the token exchange, not after it. The window is closed while the app still holds the code in memory, so the listener and the published hostname are gone before any network call to WeatherFlow begins. The exchange does not need them (it is an outbound HTTPS POST), so keeping them alive would only widen the window during which the name is on the LAN.

Supersession is the subtle one. A second pairing attempt while a prior session is still starting up would otherwise orphan it: a live listener and a published hostname that the cancel path can no longer reach, because the session reference now points elsewhere. So every site that reassigns the session cancels the old one first, and identity checks after each await let a superseded session return quietly, knowing it was already cancelled by whoever replaced it.

A crash mid-pairing self-cleans: mDNSResponder tears down the records of a client connection that has died. Withdrawal is safe to call more than once and also runs from deinit.

Withdrawal is not instant

Removing the record only stops the daemon defending the name. Passive mDNS caches (other daemons, other hosts on the network) may keep answering for compositesky.local for up to the record's TTL if the goodbye multicast does not reach them. That is why the pairing TTL is 30 s rather than something longer: it is the intended width of the stale window.

Intended, not measured

The 30 s is what this app puts in its own record, and a foreign daemon is not obliged to honour it. The test suite records observing passive caching of overheard announcements at a TTL of 120 s. Whether that reflects a daemon default applied regardless of the advertised TTL, or something specific to those runs, is not settled anywhere in this project.

So treat 30 s as the floor of the stale window rather than the ceiling: in practice a withdrawn compositesky.local may keep resolving somewhere on the network for longer, and nothing in the design depends on it not doing so.

None of this is merely believed. It is what the tests decline to assert: the live-mDNS suite deliberately does not check that a withdrawn name stops resolving, precisely because mDNS disappearance is best-effort, and asserts only that the daemon accepted the removal.

Section 8What is deliberately not done

No revoke call

Disconnecting a Tempest account deletes the Keychain token, clears the station selection and drops the station's cached records, but it does not revoke the OAuth grant at WeatherFlow. This app calls no revoke endpoint, so the grant lives on until the user removes it at tempestwx.com. The confirmation copy on screen says so in as many words, rather than implying a cleanup that does not happen.

The location is kept on purpose

Disconnect does not clear latitude, longitude or location name. The station pick wrote them and they are the app's only location: blanking them would leave the app unconfigured and stop alerts and radar as well as weather.

No retry loops on pairing setup

A publish or bind failure goes straight to manual entry rather than retrying, because the fallback is right there and a spinner that might resolve is worse than a clear door out.

One TV, one registered hostname

compositesky.local is a fixed name registered with WeatherFlow, not a per-device one. Two Apple TVs pairing on the same LAN at the same moment would collide on the name. Pairing is a seconds-long, deliberate act that happens once per TV, so this is an accepted limit rather than a bug waiting to be found.

Closing noteThis describes the code, not the plan

Everything above is a description of the shipping implementation. An earlier internal design record predates the code and has since drifted from it in several places: the number of pages the listener serves, the point at which the window is torn down relative to the token exchange, the names of the setup states, and the existence of an account-disconnect path. Where the two disagree, the code is authoritative and this page follows the code.

More engineering notes →